chore: initial rewrite 865ca012
Steve Simkins · 2026-05-16 14:38 269 file(s) · +12997 −1227
PERF_COMPARISON.md (added) +142 −0
1 +
# Andromeda: Rust vs Go Performance Comparison
2 +
3 +
Comparison of the Rust apps (current production, running in Docker) against the in-progress Go rewrites on the `chore/go-rewrite` branch.
4 +
5 +
Date: 2026-05-16
6 +
Host: Linux 7.0.3-arch1-2
7 +
8 +
## Methodology
9 +
10 +
- **Binary size**: `cargo build --release --workspace` (with `[profile.release]` defaults) vs each `go build` artifact in `apps/*-go/`. Raw stripped/unstripped binaries on disk, no UPX or other post-processing.
11 +
- **Lines of code**: Total lines in `apps/<app>/src/**/*.rs` vs `apps/<app>-go/**/*.go`. Includes blanks and comments. Shared crates counted separately.
12 +
- **Dependencies**: Direct deps parsed from `Cargo.toml` `[dependencies]` vs `require ( ... )` blocks in `go.mod` (separating direct vs total-with-indirect).
13 +
- **RAM**: Two passes.
14 +
  1. *Production snapshot* — `docker stats` against the long-running Rust containers (not apples-to-apples; reflects accumulated state).
15 +
  2. *Fair cold start* — local release binaries (Rust from `target/release/`, Go from `apps/*-go/`) launched on unused ports with `PORT`/`HOST` overrides, sampled `VmRSS` from `/proc/<pid>/status` after a 2s warmup, then terminated. Same conditions, no traffic, fresh sqlite.
16 +
17 +
## Binary Size
18 +
19 +
| App       | Rust   | Go     | Go vs Rust |
20 +
|-----------|--------|--------|------------|
21 +
| bookmarks | 14M    | 20M    | +43%       |
22 +
| cellar    | 16M    | 20M    | +25%       |
23 +
| easel     | 15M    | 20M    | +33%       |
24 +
| feeds     | 16M    | 23M    | +44%       |
25 +
| jotts     | 19M    | 21M    | +11%       |
26 +
| library   | 13M    | 20M    | +54%       |
27 +
| og        | 12M    | 14M    | +17%       |
28 +
| posts     | 16M    | 21M    | +31%       |
29 +
| shrink    | 8.2M   | 14M    | +71%       |
30 +
| sipp      | 16M    | 22M    | +38%       |
31 +
32 +
**Winner: Rust** — smaller in every app, average ~35% smaller.
33 +
34 +
## Lines of Code
35 +
36 +
Raw totals:
37 +
38 +
| App       | Rust  | Go    | Go ratio |
39 +
|-----------|-------|-------|----------|
40 +
| bookmarks | 850   | 756   | 0.89x    |
41 +
| cellar    | 2010  | 1241  | 0.62x    |
42 +
| easel     | 1156  | 946   | 0.82x    |
43 +
| feeds     | 2193  | 1981  | 0.90x    |
44 +
| jotts     | 2248  | 526   | 0.23x    |
45 +
| library   | 1021  | 882   | 0.86x    |
46 +
| og        | 399   | 295   | 0.74x    |
47 +
| posts     | 3010  | 2140  | 0.71x    |
48 +
| shrink    | 389   | 166   | 0.43x    |
49 +
| sipp      | 2455  | 655   | 0.27x    |
50 +
51 +
### TUI/CLI adjustment
52 +
53 +
`jotts` and `sipp` Rust binaries include a full TUI/CLI alongside the server. Go ports are server-only. Stripping TUI sources for a fair compare:
54 +
55 +
| App   | Rust server only | Rust TUI/CLI | Go    | Go ratio (server) |
56 +
|-------|------------------|--------------|-------|--------------------|
57 +
| jotts | 1063             | 1185         | 526   | 0.49x              |
58 +
| sipp  | 1203             | 1252         | 655   | 0.54x              |
59 +
60 +
All other apps are server-only on both sides — raw numbers are already fair.
61 +
62 +
### Shared modules
63 +
64 +
| Side  | Modules                                  | Total LOC |
65 +
|-------|------------------------------------------|-----------|
66 +
| Rust  | `crates/auth` (371), `crates/db` (833), `crates/darkmatter-css` (54) | 1258 |
67 +
| Go    | `crates-go/auth` (207), `crates-go/config` (58), `crates-go/darkmatter` (54), `crates-go/web` (72) | 391 |
68 +
69 +
**Winner: Go** — consistently fewer lines (~30–50% less for server code).
70 +
71 +
## Dependencies
72 +
73 +
| App       | Rust direct | Go direct | Go +indirect |
74 +
|-----------|-------------|-----------|---------------|
75 +
| bookmarks | 22          | 6         | 17            |
76 +
| cellar    | 24          | 5         | 16            |
77 +
| easel     | 19          | 4         | 14            |
78 +
| feeds     | 25          | 7         | 25            |
79 +
| jotts     | 29          | 6         | 17            |
80 +
| library   | 20          | 5         | 16            |
81 +
| og        | 15          | 4         | 4             |
82 +
| posts     | 26          | 6         | 17            |
83 +
| shrink    | 11          | 4         | 4             |
84 +
| sipp      | 32          | 6         | 18            |
85 +
86 +
**Winner: Go** — far fewer direct deps; stdlib carries weight. Rust transitive trees would be much larger again if expanded via `cargo tree`.
87 +
88 +
## RAM Usage
89 +
90 +
### Production snapshot (Rust in Docker, idle)
91 +
92 +
Long-running containers from `docker stats --no-stream`. Includes accumulated state (background pollers in `cellar`/`feeds` inflate RSS).
93 +
94 +
| App       | RSS    |
95 +
|-----------|--------|
96 +
| og        | 7.5M   |
97 +
| shrink    | 6.3M   |
98 +
| jotts     | 13.0M  |
99 +
| library   | 13.1M  |
100 +
| sipp      | 13.3M  |
101 +
| bookmarks | 22.1M  |
102 +
| posts     | 23.2M  |
103 +
| easel     | 31.5M  |
104 +
| feeds     | 52.1M  |
105 +
| cellar    | 64.6M  |
106 +
107 +
Not comparable to a cold Go binary — different uptime and workload.
108 +
109 +
### Fair cold start (both sides, alt ports, 2s warmup)
110 +
111 +
| App       | Rust    | Go      | Winner          |
112 +
|-----------|---------|---------|-----------------|
113 +
| bookmarks | 11.2M   | 13.6M   | Rust −18%       |
114 +
| cellar    | 10.5M   | 12.9M   | Rust −19%       |
115 +
| easel     | 21.1M   | 12.8M   | **Go −39%**     |
116 +
| feeds     | 11.6M   | 14.3M   | Rust −19%       |
117 +
| jotts     | 8.6M    | 14.2M   | Rust −39%       |
118 +
| library   | 10.1M   | 12.6M   | Rust −20%       |
119 +
| og        | 8.1M    | 10.1M   | Rust −20%       |
120 +
| posts     | 10.8M   | 14.9M   | Rust −28%       |
121 +
| shrink    | 5.6M    | 9.9M    | Rust −44%       |
122 +
| sipp      | 10.1M   | 19.6M   | Rust −48%       |
123 +
124 +
**Winner: Rust** — 9 of 10 apps. Average ~28% less RAM cold idle. Easel anomaly: Rust eagerly loads classifications/exclusion lists at boot.
125 +
126 +
## Summary
127 +
128 +
| Metric                       | Winner | Magnitude              |
129 +
|------------------------------|--------|------------------------|
130 +
| Binary size                  | Rust   | ~35% smaller avg       |
131 +
| Lines of code (server only)  | Go     | ~30–50% fewer          |
132 +
| Direct dependencies          | Go     | 4–7 vs 11–32           |
133 +
| RAM (cold start, single user)| Rust   | ~28% less avg, 9/10    |
134 +
135 +
### Context: single-user deployment
136 +
137 +
Apps are single-user. Cold-start RAM is the right benchmark — no concurrent load spike, no GC pressure differences under traffic, idle is the dominant state.
138 +
139 +
- Rust: smaller binaries, lower idle RAM, more code, more deps.
140 +
- Go: less code, fewer deps, larger binaries, higher idle RAM.
141 +
142 +
Tradeoff is roughly: pay in source-code volume + dep count to get smaller binaries and lower memory; or pay in binary size + RAM to get less code to maintain.
README.md +58 −8
2 2
3 3
![cover](https://files.stevedylan.dev/andromeda-cover.png)
4 4
5 -
A Rust workspace of minimal, self-hosted web apps. Each app compiles to a single binary powered by Axum, SQLite, and Askama templates.
5 +
A collection of minimal, self-hosted web apps. Each app compiles to a single
6 +
binary. The original implementation is a Rust workspace (Axum + Askama). A
7 +
parallel Go port lives alongside, sharing the same SQLite schemas and routes
8 +
so either implementation can serve the same data.
6 9
7 10
## Apps
8 11
20 23
| [**Library**](apps/library) | Minimal book tracker with Google Books search | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/tepdeI?referralCode=JGcIp6) |
21 24
| [**Easel**](apps/easel) | Daily public-domain painting from the Art Institute of Chicago | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/0DpuRE?referralCode=JGcIp6) |
22 25
23 -
## Shared Crates
26 +
## Go ports
27 +
28 +
The same apps are being rewritten in Go under `apps/<name>-go/`. Each one is
29 +
a separate Go module that embeds its templates and static assets and uses
30 +
shared packages from `crates-go/`. Status:
31 +
32 +
| Go app | Notes |
33 +
|---|---|
34 +
| `apps/feeds-go` | full parity |
35 +
| `apps/jotts-go` | full parity |
36 +
| `apps/og-go` | full parity |
37 +
| `apps/shrink-go` | EXIF reinjection dropped |
38 +
| `apps/bookmarks-go` | full parity |
39 +
| `apps/library-go` | full parity |
40 +
| `apps/easel-go` | full parity |
41 +
| `apps/cellar-go` | EXIF orientation auto-rotate dropped |
42 +
| `apps/posts-go` | local FS only (no R2/S3) |
43 +
| `apps/sipp-go` | server + CLI; interactive TUI not ported |
44 +
45 +
`apps/parcels-go` is intentionally not built (USPS API access has changed).
46 +
47 +
Each Go app references the shared `crates-go/` packages via `replace`
48 +
directives in its `go.mod`, so the source tree is fully self-contained.
49 +
50 +
## Shared crates
51 +
52 +
Rust:
24 53
25 54
| Crate | Description |
26 55
|---|---|
27 56
| [`andromeda-auth`](crates/auth) | Session-based password authentication |
28 57
| [`andromeda-db`](crates/db) | Shared database types and session management |
58 +
| [`andromeda-darkmatter-css`](crates/darkmatter-css) | Shared CSS + fonts |
59 +
60 +
Go (each is its own module under `crates-go/`):
61 +
62 +
| Package | Description |
63 +
|---|---|
64 +
| `crates-go/web` | HTTP helpers (embedded assets, JSON, render, redirect) |
65 +
| `crates-go/auth` | Sessions store, password/api-key verification, short-id |
66 +
| `crates-go/config` | env + `.env` loading helpers |
67 +
| `crates-go/darkmatter` | Embedded CSS + fonts, mountable on any `http.ServeMux` |
29 68
30 69
## Stack
31 70
32 -
- **Axum** - web framework
33 -
- **SQLite** (rusqlite) - storage
34 -
- **Askama** - HTML templates
35 -
- **rust-embed** - embedded static assets
36 -
- **tokio** - async runtime
71 +
Rust apps: Axum + rusqlite + Askama + rust-embed + tokio.
72 +
Go apps: stdlib `net/http` + `modernc.org/sqlite` (pure Go, no cgo) +
73 +
`html/template` + `embed.FS`. Permitted extras: `goldmark` (markdown),
74 +
`gofeed` (RSS), `golang.org/x/net/html` (HTML parsing),
75 +
`golang.org/x/image/draw` (image resize), `alecthomas/chroma` (highlight),
76 +
`golang.org/x/crypto/bcrypt` (passwords).
37 77
38 78
## Getting Started
39 79
80 +
Rust:
81 +
40 82
```bash
41 83
# Build all apps
42 84
cargo build --release
44 86
# Run a specific app
45 87
cargo run -p sipp -- server --port 3000
46 88
cargo run -p feeds
47 -
cargo run -p parcels
48 89
cargo run -p jotts
49 90
cargo run -p og
50 91
cargo run -p shrink
92 +
```
93 +
94 +
Go:
95 +
96 +
```bash
97 +
cd apps/feeds-go && cp .env.example .env && go run .
98 +
cd apps/posts-go && cp .env.example .env && go run .
99 +
# sipp-go has two binaries:
100 +
cd apps/sipp-go && go run ./cmd/server
51 101
```
52 102
53 103
Each app has its own README with detailed setup, environment variables, and deployment instructions.
apps/bookmarks-go/.env.example (added) +14 −0
1 +
HOST=127.0.0.1
2 +
PORT=3000
3 +
4 +
# SQLite file path
5 +
BOOKMARKS_DB_PATH=bookmarks.sqlite
6 +
7 +
# Admin login password
8 +
BOOKMARKS_PASSWORD=changeme
9 +
10 +
# API key for POST /api/links (omit to disable write API)
11 +
BOOKMARKS_API_KEY=
12 +
13 +
# Set true behind HTTPS to mark session cookie Secure
14 +
COOKIE_SECURE=false
apps/bookmarks-go/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t bookmarks-go -f apps/bookmarks-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/bookmarks-go/go.mod apps/bookmarks-go/go.sum ./apps/bookmarks-go/
6 +
WORKDIR /app/apps/bookmarks-go
7 +
RUN go mod download
8 +
COPY apps/bookmarks-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /bookmarks-go .
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 /bookmarks-go /usr/local/bin/bookmarks-go
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
EXPOSE 3000
18 +
CMD ["bookmarks-go"]
apps/bookmarks-go/README.md (added) +27 −0
1 +
# bookmarks-go
2 +
3 +
Go rewrite of [bookmarks](../bookmarks). Personal link saver organized by
4 +
category.
5 +
6 +
## Quickstart
7 +
8 +
```bash
9 +
cp .env.example .env
10 +
go run .
11 +
```
12 +
13 +
### Environment Variables
14 +
15 +
| Variable | Default | Description |
16 +
|---|---|---|
17 +
| `BOOKMARKS_PASSWORD` | — | Admin panel password |
18 +
| `BOOKMARKS_API_KEY` | — | API key for `POST /api/links` |
19 +
| `BOOKMARKS_DB_PATH` | `bookmarks.sqlite` | SQLite path |
20 +
| `HOST` | `127.0.0.1` | Bind host |
21 +
| `PORT` | `3000` | Server port |
22 +
| `COOKIE_SECURE` | `false` | Mark session cookie Secure |
23 +
24 +
## Routes
25 +
26 +
Public: `GET /`, `GET /api/categories`, `GET /api/links`. Auth: `GET/POST /login`,
27 +
`GET /logout`, `/admin`, `/admin/*`. API-key: `POST /api/links`.
apps/bookmarks-go/app.go (added) +74 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/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 +
	Password     string
21 +
	APIKey       string
22 +
	CookieSecure bool
23 +
}
24 +
25 +
type Category struct {
26 +
	ID       int64  `json:"id"`
27 +
	ShortID  string `json:"short_id"`
28 +
	Name     string `json:"name"`
29 +
	Position int64  `json:"position"`
30 +
}
31 +
32 +
type Link struct {
33 +
	ID         int64   `json:"id"`
34 +
	ShortID    string  `json:"short_id"`
35 +
	Title      string  `json:"title"`
36 +
	URL        string  `json:"url"`
37 +
	FaviconURL *string `json:"favicon_url,omitempty"`
38 +
	CategoryID int64   `json:"category_id"`
39 +
	CreatedAt  int64   `json:"created_at"`
40 +
}
41 +
42 +
type adminLinkRow struct {
43 +
	ShortID    string
44 +
	Title      string
45 +
	URL        string
46 +
	FaviconURL *string
47 +
	Category   string
48 +
}
49 +
50 +
type categoryGroup struct {
51 +
	Name  string
52 +
	Links []Link
53 +
}
54 +
55 +
type indexPageData struct {
56 +
	Groups []categoryGroup
57 +
}
58 +
59 +
type loginPageData struct {
60 +
	Error string
61 +
}
62 +
63 +
type adminPageData struct {
64 +
	Success    string
65 +
	Error      string
66 +
	Categories []Category
67 +
	Links      []adminLinkRow
68 +
}
69 +
70 +
type apiCreateLinkBody struct {
71 +
	Category string `json:"category"`
72 +
	Title    string `json:"title"`
73 +
	URL      string `json:"url"`
74 +
}
apps/bookmarks-go/db.go (added) +237 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
10 +
	_ "modernc.org/sqlite"
11 +
)
12 +
13 +
const schema = `
14 +
CREATE TABLE IF NOT EXISTS categories (
15 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
16 +
    short_id   TEXT NOT NULL UNIQUE,
17 +
    name       TEXT NOT NULL UNIQUE,
18 +
    position   INTEGER NOT NULL DEFAULT 0,
19 +
    created_at INTEGER NOT NULL
20 +
);
21 +
22 +
CREATE TABLE IF NOT EXISTS links (
23 +
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
24 +
    short_id     TEXT NOT NULL UNIQUE,
25 +
    title        TEXT NOT NULL,
26 +
    url          TEXT NOT NULL,
27 +
    favicon_url  TEXT,
28 +
    category_id  INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
29 +
    created_at   INTEGER NOT NULL
30 +
);
31 +
32 +
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category_id, created_at DESC);
33 +
`
34 +
35 +
func openDB(path string) (*sql.DB, error) {
36 +
	db, err := sql.Open("sqlite", path)
37 +
	if err != nil {
38 +
		return nil, err
39 +
	}
40 +
	db.SetMaxOpenConns(1)
41 +
	db.SetMaxIdleConns(1)
42 +
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
43 +
		return nil, err
44 +
	}
45 +
	if _, err := db.Exec(schema); err != nil {
46 +
		return nil, err
47 +
	}
48 +
	return db, nil
49 +
}
50 +
51 +
func listCategories(db *sql.DB) ([]Category, error) {
52 +
	rows, err := db.Query(`SELECT id, short_id, name, position FROM categories ORDER BY position ASC, name COLLATE NOCASE`)
53 +
	if err != nil {
54 +
		return nil, err
55 +
	}
56 +
	defer rows.Close()
57 +
	out := []Category{}
58 +
	for rows.Next() {
59 +
		var c Category
60 +
		if err := rows.Scan(&c.ID, &c.ShortID, &c.Name, &c.Position); err != nil {
61 +
			return nil, err
62 +
		}
63 +
		out = append(out, c)
64 +
	}
65 +
	return out, rows.Err()
66 +
}
67 +
68 +
func createCategory(db *sql.DB, name string) (*Category, error) {
69 +
	shortID, err := auth.GenerateShortID(10)
70 +
	if err != nil {
71 +
		return nil, err
72 +
	}
73 +
	now := time.Now().UTC().Unix()
74 +
	var nextPos int64
75 +
	if err := db.QueryRow(`SELECT COALESCE(MAX(position), 0) + 1 FROM categories`).Scan(&nextPos); err != nil {
76 +
		return nil, err
77 +
	}
78 +
	res, err := db.Exec(`INSERT INTO categories (short_id, name, position, created_at) VALUES (?, ?, ?, ?)`, shortID, name, nextPos, now)
79 +
	if err != nil {
80 +
		return nil, err
81 +
	}
82 +
	id, _ := res.LastInsertId()
83 +
	return &Category{ID: id, ShortID: shortID, Name: name, Position: nextPos}, nil
84 +
}
85 +
86 +
func deleteCategoryByShortID(db *sql.DB, shortID string) (bool, error) {
87 +
	res, err := db.Exec(`DELETE FROM categories WHERE short_id = ?`, shortID)
88 +
	if err != nil {
89 +
		return false, err
90 +
	}
91 +
	n, _ := res.RowsAffected()
92 +
	return n > 0, nil
93 +
}
94 +
95 +
func moveCategory(db *sql.DB, shortID string, direction int) (bool, error) {
96 +
	tx, err := db.Begin()
97 +
	if err != nil {
98 +
		return false, err
99 +
	}
100 +
	defer tx.Rollback()
101 +
102 +
	var curID, curPos int64
103 +
	err = tx.QueryRow(`SELECT id, position FROM categories WHERE short_id = ?`, shortID).Scan(&curID, &curPos)
104 +
	if errors.Is(err, sql.ErrNoRows) {
105 +
		return false, nil
106 +
	}
107 +
	if err != nil {
108 +
		return false, err
109 +
	}
110 +
111 +
	var nbID, nbPos int64
112 +
	if direction < 0 {
113 +
		err = tx.QueryRow(`SELECT id, position FROM categories WHERE position < ? ORDER BY position DESC LIMIT 1`, curPos).Scan(&nbID, &nbPos)
114 +
	} else {
115 +
		err = tx.QueryRow(`SELECT id, position FROM categories WHERE position > ? ORDER BY position ASC LIMIT 1`, curPos).Scan(&nbID, &nbPos)
116 +
	}
117 +
	if errors.Is(err, sql.ErrNoRows) {
118 +
		return false, nil
119 +
	}
120 +
	if err != nil {
121 +
		return false, err
122 +
	}
123 +
124 +
	if _, err := tx.Exec(`UPDATE categories SET position = ? WHERE id = ?`, nbPos, curID); err != nil {
125 +
		return false, err
126 +
	}
127 +
	if _, err := tx.Exec(`UPDATE categories SET position = ? WHERE id = ?`, curPos, nbID); err != nil {
128 +
		return false, err
129 +
	}
130 +
	if err := tx.Commit(); err != nil {
131 +
		return false, err
132 +
	}
133 +
	return true, nil
134 +
}
135 +
136 +
func getCategoryByName(db *sql.DB, name string) (*Category, error) {
137 +
	name = strings.TrimSpace(name)
138 +
	var c Category
139 +
	err := db.QueryRow(`SELECT id, short_id, name, position FROM categories WHERE name = ?`, name).Scan(&c.ID, &c.ShortID, &c.Name, &c.Position)
140 +
	if errors.Is(err, sql.ErrNoRows) {
141 +
		return nil, nil
142 +
	}
143 +
	if err != nil {
144 +
		return nil, err
145 +
	}
146 +
	return &c, nil
147 +
}
148 +
149 +
func listLinks(db *sql.DB) ([]Link, error) {
150 +
	rows, err := db.Query(`SELECT id, short_id, title, url, favicon_url, category_id, created_at FROM links ORDER BY created_at DESC`)
151 +
	if err != nil {
152 +
		return nil, err
153 +
	}
154 +
	defer rows.Close()
155 +
	out := []Link{}
156 +
	for rows.Next() {
157 +
		var l Link
158 +
		var fav sql.NullString
159 +
		if err := rows.Scan(&l.ID, &l.ShortID, &l.Title, &l.URL, &fav, &l.CategoryID, &l.CreatedAt); err != nil {
160 +
			return nil, err
161 +
		}
162 +
		if fav.Valid && fav.String != "" {
163 +
			s := fav.String
164 +
			l.FaviconURL = &s
165 +
		}
166 +
		out = append(out, l)
167 +
	}
168 +
	return out, rows.Err()
169 +
}
170 +
171 +
func createLink(db *sql.DB, title, url string, faviconURL *string, categoryID int64) (*Link, error) {
172 +
	shortID, err := auth.GenerateShortID(10)
173 +
	if err != nil {
174 +
		return nil, err
175 +
	}
176 +
	now := time.Now().UTC().Unix()
177 +
	var fav any
178 +
	if faviconURL != nil && *faviconURL != "" {
179 +
		fav = *faviconURL
180 +
	}
181 +
	res, err := db.Exec(`INSERT INTO links (short_id, title, url, favicon_url, category_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
182 +
		shortID, title, url, fav, categoryID, now)
183 +
	if err != nil {
184 +
		return nil, err
185 +
	}
186 +
	id, _ := res.LastInsertId()
187 +
	link := &Link{ID: id, ShortID: shortID, Title: title, URL: url, CategoryID: categoryID, CreatedAt: now}
188 +
	if faviconURL != nil && *faviconURL != "" {
189 +
		s := *faviconURL
190 +
		link.FaviconURL = &s
191 +
	}
192 +
	return link, nil
193 +
}
194 +
195 +
func listLinksMissingFavicon(db *sql.DB) ([]struct {
196 +
	ID  int64
197 +
	URL string
198 +
}, error) {
199 +
	rows, err := db.Query(`SELECT id, url FROM links WHERE favicon_url IS NULL OR favicon_url = ''`)
200 +
	if err != nil {
201 +
		return nil, err
202 +
	}
203 +
	defer rows.Close()
204 +
	var out []struct {
205 +
		ID  int64
206 +
		URL string
207 +
	}
208 +
	for rows.Next() {
209 +
		var r struct {
210 +
			ID  int64
211 +
			URL string
212 +
		}
213 +
		if err := rows.Scan(&r.ID, &r.URL); err != nil {
214 +
			return nil, err
215 +
		}
216 +
		out = append(out, r)
217 +
	}
218 +
	return out, rows.Err()
219 +
}
220 +
221 +
func updateLinkFavicon(db *sql.DB, id int64, favicon *string) error {
222 +
	var v any
223 +
	if favicon != nil && *favicon != "" {
224 +
		v = *favicon
225 +
	}
226 +
	_, err := db.Exec(`UPDATE links SET favicon_url = ? WHERE id = ?`, v, id)
227 +
	return err
228 +
}
229 +
230 +
func deleteLinkByShortID(db *sql.DB, shortID string) (bool, error) {
231 +
	res, err := db.Exec(`DELETE FROM links WHERE short_id = ?`, shortID)
232 +
	if err != nil {
233 +
		return false, err
234 +
	}
235 +
	n, _ := res.RowsAffected()
236 +
	return n > 0, nil
237 +
}
apps/bookmarks-go/docker-compose.yml (added) +20 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/bookmarks-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - BOOKMARKS_DB_PATH=/data/bookmarks-go.sqlite
12 +
      - BOOKMARKS_PASSWORD=${BOOKMARKS_PASSWORD:-changeme}
13 +
      - BOOKMARKS_API_KEY=${BOOKMARKS_API_KEY:-}
14 +
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
15 +
    volumes:
16 +
      - bookmarks-go-data:/data
17 +
    restart: unless-stopped
18 +
19 +
volumes:
20 +
  bookmarks-go-data:
apps/bookmarks-go/favicon.go (added) +79 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"io"
6 +
	"net/http"
7 +
	"net/url"
8 +
	"strings"
9 +
	"time"
10 +
11 +
	"golang.org/x/net/html"
12 +
)
13 +
14 +
const faviconUA = "andromeda-bookmarks-go/0.1 (+https://github.com/stevedylandev/andromeda)"
15 +
16 +
func discoverFavicon(ctx context.Context, pageURL string) string {
17 +
	parsed, err := url.Parse(pageURL)
18 +
	if err != nil {
19 +
		return ""
20 +
	}
21 +
	client := &http.Client{Timeout: 15 * time.Second}
22 +
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
23 +
	if err != nil {
24 +
		return ""
25 +
	}
26 +
	req.Header.Set("User-Agent", faviconUA)
27 +
	if resp, err := client.Do(req); err == nil {
28 +
		defer resp.Body.Close()
29 +
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
30 +
		if href := findFaviconHref(string(body)); href != "" {
31 +
			if u, err := parsed.Parse(href); err == nil {
32 +
				return u.String()
33 +
			}
34 +
		}
35 +
	}
36 +
	if u, err := parsed.Parse("/favicon.ico"); err == nil {
37 +
		return u.String()
38 +
	}
39 +
	return ""
40 +
}
41 +
42 +
func findFaviconHref(doc string) string {
43 +
	node, err := html.Parse(strings.NewReader(doc))
44 +
	if err != nil {
45 +
		return ""
46 +
	}
47 +
	wants := []string{"icon", "shortcut icon", "apple-touch-icon"}
48 +
	var found string
49 +
	var walk func(*html.Node)
50 +
	walk = func(n *html.Node) {
51 +
		if found != "" {
52 +
			return
53 +
		}
54 +
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") {
55 +
			rel, href := "", ""
56 +
			for _, a := range n.Attr {
57 +
				switch strings.ToLower(a.Key) {
58 +
				case "rel":
59 +
					rel = strings.ToLower(strings.TrimSpace(a.Val))
60 +
				case "href":
61 +
					href = a.Val
62 +
				}
63 +
			}
64 +
			for _, want := range wants {
65 +
				if rel == want {
66 +
					if href != "" {
67 +
						found = href
68 +
					}
69 +
					return
70 +
				}
71 +
			}
72 +
		}
73 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
74 +
			walk(c)
75 +
		}
76 +
	}
77 +
	walk(node)
78 +
	return found
79 +
}
apps/bookmarks-go/go.mod (added) +33 −0
1 +
module github.com/stevedylandev/andromeda/apps/bookmarks-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 +
	golang.org/x/net v0.41.0
11 +
	modernc.org/sqlite v1.37.1
12 +
)
13 +
14 +
require (
15 +
	github.com/dustin/go-humanize v1.0.1 // indirect
16 +
	github.com/google/uuid v1.6.0 // indirect
17 +
	github.com/mattn/go-isatty v0.0.20 // indirect
18 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
19 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
20 +
	golang.org/x/crypto v0.39.0 // indirect
21 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
22 +
	golang.org/x/sys v0.33.0 // indirect
23 +
	modernc.org/libc v1.65.7 // indirect
24 +
	modernc.org/mathutil v1.7.1 // indirect
25 +
	modernc.org/memory v1.11.0 // indirect
26 +
)
27 +
28 +
replace (
29 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
30 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
31 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
32 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
33 +
)
apps/bookmarks-go/go.sum (added) +51 −0
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/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
20 +
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
21 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
22 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
23 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
24 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
25 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
26 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
27 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
28 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
29 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
30 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
31 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
32 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
33 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
34 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
35 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
36 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
37 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
38 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
39 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
40 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
41 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
42 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
43 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
44 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
45 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
46 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
47 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
48 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
49 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
50 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
51 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/bookmarks-go/handlers_api.go (added) +100 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strings"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
func (a *App) apiListCategories(w http.ResponseWriter, r *http.Request) {
11 +
	cats, err := listCategories(a.DB)
12 +
	if err != nil {
13 +
		a.Log.Error("list categories", "err", err)
14 +
		w.WriteHeader(http.StatusInternalServerError)
15 +
		return
16 +
	}
17 +
	web.WriteJSON(w, http.StatusOK, cats)
18 +
}
19 +
20 +
func (a *App) apiListLinks(w http.ResponseWriter, r *http.Request) {
21 +
	cats, err := listCategories(a.DB)
22 +
	if err != nil {
23 +
		a.Log.Error("list categories", "err", err)
24 +
		w.WriteHeader(http.StatusInternalServerError)
25 +
		return
26 +
	}
27 +
	links, err := listLinks(a.DB)
28 +
	if err != nil {
29 +
		a.Log.Error("list links", "err", err)
30 +
		w.WriteHeader(http.StatusInternalServerError)
31 +
		return
32 +
	}
33 +
	filter := strings.TrimSpace(r.URL.Query().Get("category"))
34 +
	if filter != "" {
35 +
		var found *Category
36 +
		for i := range cats {
37 +
			if strings.EqualFold(cats[i].Name, filter) {
38 +
				found = &cats[i]
39 +
				break
40 +
			}
41 +
		}
42 +
		if found == nil {
43 +
			web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "unknown category"})
44 +
			return
45 +
		}
46 +
		out := []Link{}
47 +
		for _, l := range links {
48 +
			if l.CategoryID == found.ID {
49 +
				out = append(out, l)
50 +
			}
51 +
		}
52 +
		web.WriteJSON(w, http.StatusOK, out)
53 +
		return
54 +
	}
55 +
	grouped := map[string][]Link{}
56 +
	for _, c := range cats {
57 +
		items := []Link{}
58 +
		for _, l := range links {
59 +
			if l.CategoryID == c.ID {
60 +
				items = append(items, l)
61 +
			}
62 +
		}
63 +
		grouped[c.Name] = items
64 +
	}
65 +
	web.WriteJSON(w, http.StatusOK, grouped)
66 +
}
67 +
68 +
func (a *App) apiCreateLink(w http.ResponseWriter, r *http.Request) {
69 +
	var body apiCreateLinkBody
70 +
	if !web.DecodeJSON(w, r, &body) {
71 +
		return
72 +
	}
73 +
	title := strings.TrimSpace(body.Title)
74 +
	url := strings.TrimSpace(body.URL)
75 +
	if title == "" || url == "" {
76 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "title and url required"})
77 +
		return
78 +
	}
79 +
	cat, err := getCategoryByName(a.DB, body.Category)
80 +
	if err != nil {
81 +
		a.Log.Error("get category", "err", err)
82 +
		w.WriteHeader(http.StatusInternalServerError)
83 +
		return
84 +
	}
85 +
	if cat == nil {
86 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "unknown category"})
87 +
		return
88 +
	}
89 +
	link, err := createLink(a.DB, title, url, nil, cat.ID)
90 +
	if err != nil {
91 +
		a.Log.Error("create link", "err", err)
92 +
		w.WriteHeader(http.StatusInternalServerError)
93 +
		return
94 +
	}
95 +
	if fav := discoverFavicon(r.Context(), url); fav != "" {
96 +
		_ = updateLinkFavicon(a.DB, link.ID, &fav)
97 +
		link.FaviconURL = &fav
98 +
	}
99 +
	web.WriteJSON(w, http.StatusCreated, link)
100 +
}
apps/bookmarks-go/handlers_web.go (added) +155 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"net/http"
6 +
	"strings"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
9 +
	"github.com/stevedylandev/andromeda/crates-go/web"
10 +
)
11 +
12 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
13 +
	cats, _ := listCategories(a.DB)
14 +
	links, _ := listLinks(a.DB)
15 +
	groups := make([]categoryGroup, 0, len(cats))
16 +
	for _, c := range cats {
17 +
		group := categoryGroup{Name: c.Name}
18 +
		for _, l := range links {
19 +
			if l.CategoryID == c.ID {
20 +
				group.Links = append(group.Links, l)
21 +
			}
22 +
		}
23 +
		groups = append(groups, group)
24 +
	}
25 +
	web.Render(a.Templates, w, "index.html", indexPageData{Groups: groups}, a.Log)
26 +
}
27 +
28 +
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
29 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
30 +
}
31 +
32 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
33 +
	if a.Password == "" {
34 +
		web.RedirectWithError(w, r, "/login", "No password configured")
35 +
		return
36 +
	}
37 +
	if err := r.ParseForm(); err != nil {
38 +
		web.RedirectWithError(w, r, "/login", "Bad request")
39 +
		return
40 +
	}
41 +
	if !auth.VerifyPassword(r.FormValue("password"), a.Password) {
42 +
		web.RedirectWithError(w, r, "/login", "Invalid password")
43 +
		return
44 +
	}
45 +
	token, err := a.Sessions.Create()
46 +
	if err != nil {
47 +
		a.Log.Error("create session failed", "err", err)
48 +
		web.RedirectWithError(w, r, "/login", "Session error")
49 +
		return
50 +
	}
51 +
	a.Sessions.PruneExpired()
52 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
53 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
54 +
}
55 +
56 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
57 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
58 +
		a.Sessions.Delete(c.Value)
59 +
	}
60 +
	http.SetCookie(w, a.Sessions.ClearCookie())
61 +
	http.Redirect(w, r, "/login", http.StatusSeeOther)
62 +
}
63 +
64 +
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
65 +
	cats, _ := listCategories(a.DB)
66 +
	raw, _ := listLinks(a.DB)
67 +
	catName := map[int64]string{}
68 +
	for _, c := range cats {
69 +
		catName[c.ID] = c.Name
70 +
	}
71 +
	rows := make([]adminLinkRow, 0, len(raw))
72 +
	for _, l := range raw {
73 +
		rows = append(rows, adminLinkRow{ShortID: l.ShortID, Title: l.Title, URL: l.URL, FaviconURL: l.FaviconURL, Category: catName[l.CategoryID]})
74 +
	}
75 +
	web.Render(a.Templates, w, "admin.html", adminPageData{Success: r.URL.Query().Get("success"), Error: r.URL.Query().Get("error"), Categories: cats, Links: rows}, a.Log)
76 +
}
77 +
78 +
func (a *App) adminAddCategory(w http.ResponseWriter, r *http.Request) {
79 +
	if err := r.ParseForm(); err != nil {
80 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
81 +
		return
82 +
	}
83 +
	name := strings.TrimSpace(r.FormValue("name"))
84 +
	if name == "" {
85 +
		web.RedirectWithError(w, r, "/admin", "Name required")
86 +
		return
87 +
	}
88 +
	if _, err := createCategory(a.DB, name); err != nil {
89 +
		a.Log.Error("create category", "err", err)
90 +
		web.RedirectWithError(w, r, "/admin", "Failed to add category")
91 +
		return
92 +
	}
93 +
	web.RedirectWithSuccess(w, r, "/admin", "Category added")
94 +
}
95 +
96 +
func (a *App) adminDeleteCategory(w http.ResponseWriter, r *http.Request) {
97 +
	_, _ = deleteCategoryByShortID(a.DB, r.PathValue("short_id"))
98 +
	web.RedirectWithSuccess(w, r, "/admin", "Category removed")
99 +
}
100 +
101 +
func (a *App) adminMoveCategory(w http.ResponseWriter, r *http.Request) {
102 +
	dir := 0
103 +
	switch r.PathValue("dir") {
104 +
	case "up":
105 +
		dir = -1
106 +
	case "down":
107 +
		dir = 1
108 +
	default:
109 +
		web.RedirectWithError(w, r, "/admin", "Invalid direction")
110 +
		return
111 +
	}
112 +
	if _, err := moveCategory(a.DB, r.PathValue("short_id"), dir); err != nil {
113 +
		a.Log.Error("move category", "err", err)
114 +
		web.RedirectWithError(w, r, "/admin", "Failed to reorder")
115 +
		return
116 +
	}
117 +
	web.RedirectWithSuccess(w, r, "/admin", "Category reordered")
118 +
}
119 +
120 +
func (a *App) adminAddLink(w http.ResponseWriter, r *http.Request) {
121 +
	if err := r.ParseForm(); err != nil {
122 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
123 +
		return
124 +
	}
125 +
	title := strings.TrimSpace(r.FormValue("title"))
126 +
	url := strings.TrimSpace(r.FormValue("url"))
127 +
	if title == "" || url == "" {
128 +
		web.RedirectWithError(w, r, "/admin", "Title and URL required")
129 +
		return
130 +
	}
131 +
	cat, err := getCategoryByName(a.DB, r.FormValue("category"))
132 +
	if err != nil || cat == nil {
133 +
		web.RedirectWithError(w, r, "/admin", "Unknown category")
134 +
		return
135 +
	}
136 +
	link, err := createLink(a.DB, title, url, nil, cat.ID)
137 +
	if err != nil {
138 +
		a.Log.Error("create link", "err", err)
139 +
		web.RedirectWithError(w, r, "/admin", "Failed to add link")
140 +
		return
141 +
	}
142 +
	go func(id int64, u string) {
143 +
		if fav := discoverFavicon(context.Background(), u); fav != "" {
144 +
			if err := updateLinkFavicon(a.DB, id, &fav); err != nil {
145 +
				a.Log.Error("update favicon", "err", err)
146 +
			}
147 +
		}
148 +
	}(link.ID, url)
149 +
	web.RedirectWithSuccess(w, r, "/admin", "Link added")
150 +
}
151 +
152 +
func (a *App) adminDeleteLink(w http.ResponseWriter, r *http.Request) {
153 +
	_, _ = deleteLinkByShortID(a.DB, r.PathValue("short_id"))
154 +
	web.RedirectWithSuccess(w, r, "/admin", "Link removed")
155 +
}
apps/bookmarks-go/main.go (added) +72 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"html/template"
6 +
	"log"
7 +
	"log/slog"
8 +
	"net/http"
9 +
	"os"
10 +
	"time"
11 +
12 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
13 +
	"github.com/stevedylandev/andromeda/crates-go/config"
14 +
)
15 +
16 +
func main() {
17 +
	config.LoadDotEnv(".env")
18 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
19 +
20 +
	dbPath := config.Getenv("BOOKMARKS_DB_PATH", "bookmarks.sqlite")
21 +
	db, err := openDB(dbPath)
22 +
	if err != nil {
23 +
		log.Fatal(err)
24 +
	}
25 +
	defer db.Close()
26 +
27 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
28 +
	if err := sessions.EnsureSchema(); err != nil {
29 +
		log.Fatal(err)
30 +
	}
31 +
	sessions.PruneExpired()
32 +
33 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
34 +
	app := &App{
35 +
		DB:           db,
36 +
		Log:          logger,
37 +
		Templates:    tmpl,
38 +
		Sessions:     sessions,
39 +
		Password:     os.Getenv("BOOKMARKS_PASSWORD"),
40 +
		APIKey:       os.Getenv("BOOKMARKS_API_KEY"),
41 +
		CookieSecure: sessions.CookieSecure,
42 +
	}
43 +
44 +
	go app.faviconBackfill(context.Background())
45 +
46 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
47 +
	logger.Info("bookmarks-go server running", "addr", addr)
48 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
49 +
		log.Fatal(err)
50 +
	}
51 +
}
52 +
53 +
func (a *App) faviconBackfill(ctx context.Context) {
54 +
	pending, err := listLinksMissingFavicon(a.DB)
55 +
	if err != nil {
56 +
		a.Log.Error("favicon backfill query", "err", err)
57 +
		return
58 +
	}
59 +
	if len(pending) == 0 {
60 +
		return
61 +
	}
62 +
	a.Log.Info("favicon backfill", "count", len(pending))
63 +
	for _, row := range pending {
64 +
		if fav := discoverFavicon(ctx, row.URL); fav != "" {
65 +
			if err := updateLinkFavicon(a.DB, row.ID, &fav); err != nil {
66 +
				a.Log.Error("favicon backfill update", "id", row.ID, "err", err)
67 +
			}
68 +
		}
69 +
		time.Sleep(250 * time.Millisecond)
70 +
	}
71 +
	a.Log.Info("favicon backfill done")
72 +
}
apps/bookmarks-go/routes.go (added) +39 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
7 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
10 +
11 +
func (a *App) routes() *http.ServeMux {
12 +
	mux := http.NewServeMux()
13 +
14 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
15 +
		return a.Sessions.RequireSession("/login", next)
16 +
	}
17 +
	requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
18 +
		return auth.RequireAPIKey(a.APIKey, next)
19 +
	}
20 +
21 +
	mux.HandleFunc("GET /", a.indexHandler)
22 +
	mux.HandleFunc("GET /login", a.loginGetHandler)
23 +
	mux.HandleFunc("POST /login", a.loginPostHandler)
24 +
	mux.HandleFunc("GET /logout", a.logoutHandler)
25 +
	mux.HandleFunc("GET /admin", requireSession(a.adminHandler))
26 +
	mux.HandleFunc("POST /admin/categories", requireSession(a.adminAddCategory))
27 +
	mux.HandleFunc("POST /admin/categories/{short_id}/delete", requireSession(a.adminDeleteCategory))
28 +
	mux.HandleFunc("POST /admin/categories/{short_id}/move/{dir}", requireSession(a.adminMoveCategory))
29 +
	mux.HandleFunc("POST /admin/links", requireSession(a.adminAddLink))
30 +
	mux.HandleFunc("POST /admin/links/{short_id}/delete", requireSession(a.adminDeleteLink))
31 +
32 +
	mux.HandleFunc("GET /api/categories", a.apiListCategories)
33 +
	mux.HandleFunc("GET /api/links", a.apiListLinks)
34 +
	mux.HandleFunc("POST /api/links", requireAPIKey(a.apiCreateLink))
35 +
36 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
37 +
	darkmatter.Mount(mux, "/assets")
38 +
	return mux
39 +
}
apps/bookmarks-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/bookmarks-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/bookmarks-go/static/styles.css (added) +221 −0
1 +
/* feeds — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
/* The logo wraps an h1 in feeds markup. */
6 +
7 +
.logo h1 {
8 +
  font-size: 28px;
9 +
  font-weight: 700;
10 +
  text-transform: uppercase;
11 +
}
12 +
13 +
.about {
14 +
  display: flex;
15 +
  flex-direction: column;
16 +
  gap: 0.5rem;
17 +
  font-size: 14px;
18 +
  line-height: 1.25rem;
19 +
}
20 +
21 +
/* Feeds list */
22 +
23 +
.feeds-list {
24 +
  width: 100%;
25 +
  display: flex;
26 +
  flex-direction: column;
27 +
  gap: 1.5rem;
28 +
}
29 +
30 +
.feed-item {
31 +
  display: flex;
32 +
  flex-direction: column;
33 +
  gap: 0.5rem;
34 +
  padding: 1rem 0;
35 +
  border-bottom: 1px solid #333;
36 +
}
37 +
38 +
.feed-item:last-child {
39 +
  border-bottom: none;
40 +
}
41 +
42 +
.feed-meta {
43 +
  display: flex;
44 +
  justify-content: space-between;
45 +
  align-items: center;
46 +
  font-size: 12px;
47 +
  opacity: 0.5;
48 +
}
49 +
50 +
.feed-source {
51 +
  font-weight: 700;
52 +
}
53 +
54 +
.feed-title {
55 +
  font-size: 16px;
56 +
  font-weight: 400;
57 +
  line-height: 1.4;
58 +
}
59 +
60 +
.feed-title a {
61 +
  text-decoration: none;
62 +
}
63 +
64 +
.feed-author {
65 +
  font-size: 12px;
66 +
  opacity: 0.5;
67 +
  font-style: italic;
68 +
}
69 +
70 +
#feed-urls {
71 +
  font-size: 12px;
72 +
  opacity: 0.5;
73 +
}
74 +
75 +
.no-feeds,
76 +
#loading {
77 +
  text-align: center;
78 +
  opacity: 0.5;
79 +
  padding: 2rem;
80 +
}
81 +
82 +
#error {
83 +
  text-align: center;
84 +
  padding: 2rem;
85 +
}
86 +
87 +
/* Admin forms */
88 +
89 +
.admin-form {
90 +
  display: flex;
91 +
  flex-direction: column;
92 +
  gap: 0.75rem;
93 +
  width: 100%;
94 +
}
95 +
96 +
.admin-form h3 {
97 +
  font-size: 14px;
98 +
  font-weight: 400;
99 +
  opacity: 0.5;
100 +
}
101 +
102 +
.admin-notice,
103 +
.hint {
104 +
  font-size: 12px;
105 +
  opacity: 0.5;
106 +
  line-height: 1.4;
107 +
}
108 +
109 +
/* Discover panel */
110 +
111 +
.discover-row {
112 +
  display: flex;
113 +
  gap: 0.5rem;
114 +
  width: 100%;
115 +
}
116 +
117 +
.discover-row input {
118 +
  flex: 1;
119 +
}
120 +
121 +
.discover-status {
122 +
  font-size: 12px;
123 +
}
124 +
125 +
.discover-results {
126 +
  display: flex;
127 +
  flex-direction: column;
128 +
  gap: 0.25rem;
129 +
  width: 100%;
130 +
}
131 +
132 +
.discover-result-item {
133 +
  background: #121113;
134 +
  color: #ffffff;
135 +
  border: 1px solid #333;
136 +
  padding: 8px 10px;
137 +
  font-size: 12px;
138 +
  text-align: left;
139 +
  cursor: pointer;
140 +
  width: 100%;
141 +
  white-space: nowrap;
142 +
  overflow: hidden;
143 +
  text-overflow: ellipsis;
144 +
  opacity: 0.7;
145 +
  border-radius: 0;
146 +
  -webkit-appearance: none;
147 +
  appearance: none;
148 +
}
149 +
150 +
.discover-result-item:hover {
151 +
  border-color: #555;
152 +
  opacity: 1;
153 +
}
154 +
155 +
.discover-result-item.active {
156 +
  border-color: #ffffff;
157 +
  opacity: 1;
158 +
}
159 +
160 +
/* Admin subs */
161 +
162 +
.admin-subs {
163 +
  width: 100%;
164 +
  display: flex;
165 +
  flex-direction: column;
166 +
  gap: 1rem;
167 +
}
168 +
169 +
.admin-subs h3 {
170 +
  font-size: 14px;
171 +
  opacity: 0.5;
172 +
  font-weight: 400;
173 +
}
174 +
175 +
.feed-item form.inline {
176 +
  display: flex;
177 +
  gap: 0.5rem;
178 +
  align-items: center;
179 +
}
180 +
181 +
.feed-item form.inline input {
182 +
  flex: 1;
183 +
}
184 +
185 +
/* Generic .danger on buttons (used in admin) */
186 +
187 +
button.danger,
188 +
.btn.danger {
189 +
  opacity: 0.5;
190 +
}
191 +
192 +
button.danger:hover,
193 +
.btn.danger:hover {
194 +
  opacity: 0.3;
195 +
}
196 +
197 +
/* Category list (admin) */
198 +
199 +
.category-list {
200 +
  list-style: none;
201 +
  margin-left: 0;
202 +
}
203 +
204 +
.category-list li {
205 +
  display: flex;
206 +
  justify-content: space-between;
207 +
  align-items: center;
208 +
  padding: 0.25rem 0;
209 +
}
210 +
211 +
@media (max-width: 480px) {
212 +
  .feed-meta {
213 +
    flex-direction: column;
214 +
    align-items: flex-start;
215 +
    gap: 0.25rem;
216 +
  }
217 +
218 +
  .feed-title {
219 +
    font-size: 14px;
220 +
  }
221 +
}
apps/bookmarks-go/templates/admin.html (added) +130 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Bookmarks | Admin</title>
14 +
    <style>
15 +
      .section-label {
16 +
        font-size: 14px;
17 +
        font-weight: 400;
18 +
        opacity: 0.5;
19 +
        margin: 0 0 0.5rem;
20 +
      }
21 +
      section { width: 100%; margin-top: 1.5rem; }
22 +
    </style>
23 +
  </head>
24 +
  <body>
25 +
    <div class="header">
26 +
      <a href="/" class="logo">BOOKMARKS</a>
27 +
      <nav class="links">
28 +
        <a href="/logout">logout</a>
29 +
      </nav>
30 +
    </div>
31 +
32 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
33 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
34 +
35 +
    <section>
36 +
      <h3 class="section-label">Categories</h3>
37 +
      <form class="form" method="POST" action="/admin/categories">
38 +
        <div class="form-row">
39 +
          <div class="form-field">
40 +
            <input type="text" name="name" placeholder="new category" required />
41 +
          </div>
42 +
          <button type="submit">Add</button>
43 +
        </div>
44 +
      </form>
45 +
      {{if not .Categories}}
46 +
      <p class="empty">No categories yet.</p>
47 +
      {{else}}
48 +
      <ul class="admin-list">
49 +
        {{range .Categories}}
50 +
        <li class="admin-list-item">
51 +
          <div class="admin-list-info">
52 +
            <span class="admin-list-title">{{.Name}}</span>
53 +
          </div>
54 +
          <div class="admin-list-actions">
55 +
            <form method="POST" action="/admin/categories/{{.ShortID}}/move/up" class="inline-form">
56 +
              <button type="submit" class="link-button">↑</button>
57 +
            </form>
58 +
            <form method="POST" action="/admin/categories/{{.ShortID}}/move/down" class="inline-form">
59 +
              <button type="submit" class="link-button">↓</button>
60 +
            </form>
61 +
            <form method="POST" action="/admin/categories/{{.ShortID}}/delete" class="inline-form">
62 +
              <button type="submit" class="link-button danger">delete</button>
63 +
            </form>
64 +
          </div>
65 +
        </li>
66 +
        {{end}}
67 +
      </ul>
68 +
      {{end}}
69 +
    </section>
70 +
71 +
    <section>
72 +
      <h3 class="section-label">Add Link</h3>
73 +
      {{if not .Categories}}
74 +
      <p class="empty">Add a category first.</p>
75 +
      {{else}}
76 +
      <form class="form" method="POST" action="/admin/links">
77 +
        <div class="form-field">
78 +
          <label for="title">Title</label>
79 +
          <input type="text" id="title" name="title" required />
80 +
        </div>
81 +
        <div class="form-field">
82 +
          <label for="url">URL</label>
83 +
          <input type="url" id="url" name="url" required />
84 +
        </div>
85 +
        <div class="form-field">
86 +
          <label for="category">Category</label>
87 +
          <select id="category" name="category" required>
88 +
            {{range .Categories}}
89 +
            <option value="{{.Name}}">{{.Name}}</option>
90 +
            {{end}}
91 +
          </select>
92 +
        </div>
93 +
        <div class="form-actions">
94 +
          <button type="submit">Add link</button>
95 +
        </div>
96 +
      </form>
97 +
      {{end}}
98 +
    </section>
99 +
100 +
    <section>
101 +
      <h3 class="section-label">Links</h3>
102 +
      {{if not .Links}}
103 +
      <p class="empty">No links yet.</p>
104 +
      {{else}}
105 +
      <ul class="admin-list">
106 +
        {{range .Links}}
107 +
        <li class="admin-list-item">
108 +
          <div class="admin-list-info">
109 +
            <a class="admin-list-title" href="{{.URL}}" target="_blank" rel="noopener noreferrer">
110 +
              {{if .FaviconURL}}
111 +
              <img class="favicon" src="{{.FaviconURL}}" alt="" width="16" height="16" loading="lazy" />
112 +
              {{end}}
113 +
              {{.Title}}
114 +
            </a>
115 +
            <div class="admin-list-meta">
116 +
              <span class="tag">{{.Category}}</span>
117 +
            </div>
118 +
          </div>
119 +
          <div class="admin-list-actions">
120 +
            <form method="POST" action="/admin/links/{{.ShortID}}/delete" class="inline-form">
121 +
              <button type="submit" class="link-button danger">delete</button>
122 +
            </form>
123 +
          </div>
124 +
        </li>
125 +
        {{end}}
126 +
      </ul>
127 +
      {{end}}
128 +
    </section>
129 +
  </body>
130 +
</html>
apps/bookmarks-go/templates/index.html (added) +59 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Bookmarks</title>
14 +
    <style>
15 +
      .category-heading {
16 +
        font-size: 14px;
17 +
        font-weight: 400;
18 +
        opacity: 0.5;
19 +
        text-transform: uppercase;
20 +
        letter-spacing: 0.05em;
21 +
      }
22 +
    </style>
23 +
  </head>
24 +
  <body>
25 +
    <div class="header">
26 +
      <a href="/" class="logo">BOOKMARKS</a>
27 +
      <nav class="links">
28 +
        <a href="/admin">add</a>
29 +
      </nav>
30 +
    </div>
31 +
32 +
    {{if not .Groups}}
33 +
    <p class="empty">No categories yet.</p>
34 +
    {{else}}
35 +
    {{range .Groups}}
36 +
    <section>
37 +
      <h2 class="category-heading">{{.Name}}</h2>
38 +
      {{if not .Links}}
39 +
      <p class="empty">No links.</p>
40 +
      {{else}}
41 +
      <ul class="item-list">
42 +
        {{range .Links}}
43 +
        <li class="item">
44 +
          <a class="item-title" href="{{.URL}}" target="_blank" rel="noopener noreferrer">
45 +
            {{if .FaviconURL}}
46 +
            <img class="favicon" src="{{.FaviconURL}}" alt="" width="16" height="16" loading="lazy" />
47 +
            {{end}}
48 +
            {{.Title}}
49 +
          </a>
50 +
          <div class="item-meta">{{.URL}}</div>
51 +
        </li>
52 +
        {{end}}
53 +
      </ul>
54 +
      {{end}}
55 +
    </section>
56 +
    {{end}}
57 +
    {{end}}
58 +
  </body>
59 +
</html>
apps/bookmarks-go/templates/login.html (added) +34 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Bookmarks | Login</title>
14 +
  </head>
15 +
  <body>
16 +
    <div class="header">
17 +
      <a href="/" class="logo">BOOKMARKS</a>
18 +
    </div>
19 +
20 +
    {{if .Error}}
21 +
    <p class="error">{{.Error}}</p>
22 +
    {{end}}
23 +
24 +
    <form class="form" method="POST" action="/login">
25 +
      <div class="form-field">
26 +
        <label for="password">Password</label>
27 +
        <input type="password" id="password" name="password" required autofocus />
28 +
      </div>
29 +
      <div class="form-actions">
30 +
        <button type="submit">Login</button>
31 +
      </div>
32 +
    </form>
33 +
  </body>
34 +
</html>
apps/cellar-go/.env.example (added) +9 −0
1 +
CELLAR_PASSWORD=changeme
2 +
CELLAR_DB_PATH=cellar.sqlite
3 +
ANTHROPIC_API_KEY=
4 +
COOKIE_SECURE=false
5 +
HOST=127.0.0.1
6 +
PORT=3000
7 +
SITE_URL=http://localhost:3000
8 +
SITE_TITLE=Cellar
9 +
SITE_DESCRIPTION=Personal wine tasting log
apps/cellar-go/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t cellar-go -f apps/cellar-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/cellar-go/go.mod apps/cellar-go/go.sum ./apps/cellar-go/
6 +
WORKDIR /app/apps/cellar-go
7 +
RUN go mod download
8 +
COPY apps/cellar-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /cellar-go .
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 /cellar-go /usr/local/bin/cellar-go
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
EXPOSE 3000
18 +
CMD ["cellar-go"]
apps/cellar-go/README.md (added) +13 −0
1 +
# cellar-go
2 +
3 +
Go rewrite of [cellar](../cellar). Wine tasting log with optional Anthropic
4 +
vision (label analysis) and per-wine RSS feed.
5 +
6 +
## Notes vs Rust version
7 +
8 +
- Anthropic `/v1/messages` called via stdlib `net/http` (no SDK).
9 +
- Image processing uses stdlib `image` decode + JPEG re-encode at quality 75.
10 +
  EXIF orientation is not respected; rotate before upload if needed.
11 +
- Multipart upload limit kept at 10 MB.
12 +
13 +
See `.env.example` for config.
apps/cellar-go/app.go (added) +101 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/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 +
	AppPassword     string
21 +
	CookieSecure    bool
22 +
	AnthropicAPIKey string
23 +
	SiteURL         string
24 +
	SiteTitle       string
25 +
	SiteDescription string
26 +
}
27 +
28 +
type Wine struct {
29 +
	ID             int64  `json:"id"`
30 +
	ShortID        string `json:"short_id"`
31 +
	Name           string `json:"name"`
32 +
	Origin         string `json:"origin"`
33 +
	Grape          string `json:"grape"`
34 +
	Notes          string `json:"notes"`
35 +
	HasImage       bool   `json:"has_image"`
36 +
	ImageMime      string `json:"image_mime,omitempty"`
37 +
	Sweetness      int    `json:"sweetness"`
38 +
	Acidity        int    `json:"acidity"`
39 +
	Tannin         int    `json:"tannin"`
40 +
	Alcohol        int    `json:"alcohol"`
41 +
	Body           int    `json:"body"`
42 +
	Clarity        int    `json:"clarity"`
43 +
	ColorIntensity int    `json:"color_intensity"`
44 +
	AromaIntensity int    `json:"aroma_intensity"`
45 +
	NoseComplexity int    `json:"nose_complexity"`
46 +
	Background     string `json:"background"`
47 +
	CreatedAt      string `json:"created_at"`
48 +
	Wishlist       bool   `json:"wishlist"`
49 +
}
50 +
51 +
type WineInput struct {
52 +
	Name           string
53 +
	Origin         string
54 +
	Grape          string
55 +
	Notes          string
56 +
	Background     string
57 +
	Sweetness      int
58 +
	Acidity        int
59 +
	Tannin         int
60 +
	Alcohol        int
61 +
	Body           int
62 +
	Clarity        int
63 +
	ColorIntensity int
64 +
	AromaIntensity int
65 +
	NoseComplexity int
66 +
}
67 +
68 +
type wineWithSVG struct {
69 +
	Wine        Wine
70 +
	PentagonSVG template.HTML
71 +
}
72 +
73 +
type indexPageData struct {
74 +
	Wines []wineWithSVG
75 +
}
76 +
77 +
type loginPageData struct {
78 +
	Error string
79 +
	Next  string
80 +
}
81 +
82 +
type wineDetailPageData struct {
83 +
	Wine        Wine
84 +
	PentagonSVG template.HTML
85 +
	BarsSVG     template.HTML
86 +
}
87 +
88 +
type adminPageData struct {
89 +
	Wines []Wine
90 +
}
91 +
92 +
type wineFormPageData struct {
93 +
	Wine            *Wine
94 +
	Error           string
95 +
	HasAnthropicKey bool
96 +
}
97 +
98 +
type wishlistPageData struct {
99 +
	Wines   []Wine
100 +
	IsAdmin bool
101 +
}
apps/cellar-go/claude.go (added) +113 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"context"
6 +
	"encoding/base64"
7 +
	"encoding/json"
8 +
	"fmt"
9 +
	"io"
10 +
	"net/http"
11 +
	"strings"
12 +
	"time"
13 +
)
14 +
15 +
type AnalyzeResult struct {
16 +
	Name       string `json:"name"`
17 +
	Origin     string `json:"origin"`
18 +
	Grape      string `json:"grape"`
19 +
	Background string `json:"background"`
20 +
}
21 +
22 +
type claudeImageSource struct {
23 +
	Type      string `json:"type"`
24 +
	MediaType string `json:"media_type"`
25 +
	Data      string `json:"data"`
26 +
}
27 +
28 +
type claudeContent struct {
29 +
	Type   string             `json:"type"`
30 +
	Source *claudeImageSource `json:"source,omitempty"`
31 +
	Text   string             `json:"text,omitempty"`
32 +
}
33 +
34 +
type claudeMessage struct {
35 +
	Role    string          `json:"role"`
36 +
	Content []claudeContent `json:"content"`
37 +
}
38 +
39 +
type claudeRequest struct {
40 +
	Model     string          `json:"model"`
41 +
	MaxTokens int             `json:"max_tokens"`
42 +
	Messages  []claudeMessage `json:"messages"`
43 +
}
44 +
45 +
type claudeResponse struct {
46 +
	Content []struct {
47 +
		Text string `json:"text"`
48 +
	} `json:"content"`
49 +
}
50 +
51 +
const claudeModel = "claude-sonnet-4-20250514"
52 +
const claudePrompt = `Look at this wine bottle label. Return a JSON object with exactly these fields: {"name": "the full wine name", "origin": "region and/or country", "grape": "grape variety or blend", "background": "brief background about the wine and the winery, including any notable history or interesting facts"}. If you cannot determine a field, use an empty string. Respond with ONLY the JSON, no other text.`
53 +
54 +
func analyzeWineImage(ctx context.Context, apiKey string, imageBytes []byte, mediaType string) (*AnalyzeResult, error) {
55 +
	encoded := base64.StdEncoding.EncodeToString(imageBytes)
56 +
	req := claudeRequest{
57 +
		Model:     claudeModel,
58 +
		MaxTokens: 1024,
59 +
		Messages: []claudeMessage{{
60 +
			Role: "user",
61 +
			Content: []claudeContent{
62 +
				{Type: "image", Source: &claudeImageSource{Type: "base64", MediaType: mediaType, Data: encoded}},
63 +
				{Type: "text", Text: claudePrompt},
64 +
			},
65 +
		}},
66 +
	}
67 +
	body, err := json.Marshal(req)
68 +
	if err != nil {
69 +
		return nil, err
70 +
	}
71 +
	client := &http.Client{Timeout: 60 * time.Second}
72 +
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(body))
73 +
	if err != nil {
74 +
		return nil, err
75 +
	}
76 +
	httpReq.Header.Set("x-api-key", apiKey)
77 +
	httpReq.Header.Set("anthropic-version", "2023-06-01")
78 +
	httpReq.Header.Set("content-type", "application/json")
79 +
	resp, err := client.Do(httpReq)
80 +
	if err != nil {
81 +
		return nil, fmt.Errorf("Request failed: %w", err)
82 +
	}
83 +
	defer resp.Body.Close()
84 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
85 +
		buf, _ := io.ReadAll(resp.Body)
86 +
		return nil, fmt.Errorf("API error %s: %s", resp.Status, string(buf))
87 +
	}
88 +
	var cr claudeResponse
89 +
	if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
90 +
		return nil, fmt.Errorf("Failed to parse response: %w", err)
91 +
	}
92 +
	var text string
93 +
	for _, c := range cr.Content {
94 +
		if c.Text != "" {
95 +
			text = c.Text
96 +
			break
97 +
		}
98 +
	}
99 +
	if text == "" {
100 +
		return nil, fmt.Errorf("No text in response")
101 +
	}
102 +
	text = strings.TrimSpace(text)
103 +
	if i := strings.Index(text, "{"); i >= 0 {
104 +
		if j := strings.LastIndex(text, "}"); j > i {
105 +
			text = text[i : j+1]
106 +
		}
107 +
	}
108 +
	var result AnalyzeResult
109 +
	if err := json.Unmarshal([]byte(text), &result); err != nil {
110 +
		return nil, fmt.Errorf("Failed to parse JSON: %w", err)
111 +
	}
112 +
	return &result, nil
113 +
}
apps/cellar-go/db.go (added) +198 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 +
	_ "modernc.org/sqlite"
9 +
)
10 +
11 +
const cellarSchema = `
12 +
CREATE TABLE IF NOT EXISTS wines (
13 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
14 +
    short_id        TEXT NOT NULL UNIQUE,
15 +
    name            TEXT NOT NULL,
16 +
    origin          TEXT NOT NULL,
17 +
    grape           TEXT NOT NULL,
18 +
    notes           TEXT NOT NULL,
19 +
    image           BLOB,
20 +
    image_mime      TEXT,
21 +
    sweetness       INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
22 +
    acidity         INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
23 +
    tannin          INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
24 +
    alcohol         INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
25 +
    body            INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
26 +
    clarity         INTEGER NOT NULL DEFAULT 3,
27 +
    color_intensity INTEGER NOT NULL DEFAULT 3,
28 +
    aroma_intensity INTEGER NOT NULL DEFAULT 3,
29 +
    nose_complexity INTEGER NOT NULL DEFAULT 3,
30 +
    background      TEXT NOT NULL DEFAULT '',
31 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
32 +
    wishlist        INTEGER NOT NULL DEFAULT 0
33 +
);
34 +
`
35 +
36 +
const wineCols = `id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, COALESCE(image_mime, ''), sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist`
37 +
38 +
func openDB(path string) (*sql.DB, error) {
39 +
	db, err := sql.Open("sqlite", path)
40 +
	if err != nil {
41 +
		return nil, err
42 +
	}
43 +
	db.SetMaxOpenConns(1)
44 +
	db.SetMaxIdleConns(1)
45 +
	if _, err := db.Exec(cellarSchema); err != nil {
46 +
		return nil, err
47 +
	}
48 +
	return db, nil
49 +
}
50 +
51 +
func scanWine(s interface{ Scan(...any) error }) (*Wine, error) {
52 +
	var w Wine
53 +
	var hasImage int
54 +
	err := s.Scan(&w.ID, &w.ShortID, &w.Name, &w.Origin, &w.Grape, &w.Notes,
55 +
		&hasImage, &w.ImageMime,
56 +
		&w.Sweetness, &w.Acidity, &w.Tannin, &w.Alcohol, &w.Body,
57 +
		&w.Clarity, &w.ColorIntensity, &w.AromaIntensity, &w.NoseComplexity,
58 +
		&w.Background, &w.CreatedAt, &w.Wishlist)
59 +
	if errors.Is(err, sql.ErrNoRows) {
60 +
		return nil, nil
61 +
	}
62 +
	if err != nil {
63 +
		return nil, err
64 +
	}
65 +
	w.HasImage = hasImage != 0
66 +
	return &w, nil
67 +
}
68 +
69 +
func createWine(db *sql.DB, in WineInput, wishlist bool) (*Wine, error) {
70 +
	shortID, err := auth.GenerateShortID(10)
71 +
	if err != nil {
72 +
		return nil, err
73 +
	}
74 +
	res, err := db.Exec(
75 +
		`INSERT INTO wines (short_id, name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist)
76 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
77 +
		shortID, in.Name, in.Origin, in.Grape, in.Notes,
78 +
		in.Sweetness, in.Acidity, in.Tannin, in.Alcohol, in.Body,
79 +
		in.Clarity, in.ColorIntensity, in.AromaIntensity, in.NoseComplexity,
80 +
		in.Background, boolToInt(wishlist),
81 +
	)
82 +
	if err != nil {
83 +
		return nil, err
84 +
	}
85 +
	id, _ := res.LastInsertId()
86 +
	return scanWine(db.QueryRow(`SELECT `+wineCols+` FROM wines WHERE id = ?`, id))
87 +
}
88 +
89 +
func getCellarWines(db *sql.DB) ([]Wine, error) {
90 +
	rows, err := db.Query(`SELECT ` + wineCols + ` FROM wines WHERE wishlist = 0 ORDER BY id DESC`)
91 +
	if err != nil {
92 +
		return nil, err
93 +
	}
94 +
	defer rows.Close()
95 +
	out := []Wine{}
96 +
	for rows.Next() {
97 +
		w, err := scanWine(rows)
98 +
		if err != nil {
99 +
			return nil, err
100 +
		}
101 +
		out = append(out, *w)
102 +
	}
103 +
	return out, rows.Err()
104 +
}
105 +
106 +
func getWishlistWines(db *sql.DB) ([]Wine, error) {
107 +
	rows, err := db.Query(`SELECT ` + wineCols + ` FROM wines WHERE wishlist = 1 ORDER BY id DESC`)
108 +
	if err != nil {
109 +
		return nil, err
110 +
	}
111 +
	defer rows.Close()
112 +
	out := []Wine{}
113 +
	for rows.Next() {
114 +
		w, err := scanWine(rows)
115 +
		if err != nil {
116 +
			return nil, err
117 +
		}
118 +
		out = append(out, *w)
119 +
	}
120 +
	return out, rows.Err()
121 +
}
122 +
123 +
func getWineByShortID(db *sql.DB, shortID string) (*Wine, error) {
124 +
	return scanWine(db.QueryRow(`SELECT `+wineCols+` FROM wines WHERE short_id = ?`, shortID))
125 +
}
126 +
127 +
func getWineImage(db *sql.DB, shortID string) ([]byte, string, error) {
128 +
	var img []byte
129 +
	var mime string
130 +
	err := db.QueryRow(`SELECT image, COALESCE(image_mime, '') FROM wines WHERE short_id = ? AND image IS NOT NULL`, shortID).Scan(&img, &mime)
131 +
	if errors.Is(err, sql.ErrNoRows) {
132 +
		return nil, "", nil
133 +
	}
134 +
	if err != nil {
135 +
		return nil, "", err
136 +
	}
137 +
	return img, mime, nil
138 +
}
139 +
140 +
func updateWine(db *sql.DB, shortID string, in WineInput) (*Wine, error) {
141 +
	res, err := db.Exec(
142 +
		`UPDATE wines SET name = ?, origin = ?, grape = ?, notes = ?,
143 +
		 sweetness = ?, acidity = ?, tannin = ?, alcohol = ?, body = ?,
144 +
		 clarity = ?, color_intensity = ?, aroma_intensity = ?, nose_complexity = ?,
145 +
		 background = ? WHERE short_id = ?`,
146 +
		in.Name, in.Origin, in.Grape, in.Notes,
147 +
		in.Sweetness, in.Acidity, in.Tannin, in.Alcohol, in.Body,
148 +
		in.Clarity, in.ColorIntensity, in.AromaIntensity, in.NoseComplexity,
149 +
		in.Background, shortID,
150 +
	)
151 +
	if err != nil {
152 +
		return nil, err
153 +
	}
154 +
	if n, _ := res.RowsAffected(); n == 0 {
155 +
		return nil, nil
156 +
	}
157 +
	return getWineByShortID(db, shortID)
158 +
}
159 +
160 +
func updateWishlistWine(db *sql.DB, shortID, name, origin, grape, notes, background string) (*Wine, error) {
161 +
	res, err := db.Exec(
162 +
		`UPDATE wines SET name = ?, origin = ?, grape = ?, notes = ?, background = ? WHERE short_id = ? AND wishlist = 1`,
163 +
		name, origin, grape, notes, background, shortID,
164 +
	)
165 +
	if err != nil {
166 +
		return nil, err
167 +
	}
168 +
	if n, _ := res.RowsAffected(); n == 0 {
169 +
		return nil, nil
170 +
	}
171 +
	return getWineByShortID(db, shortID)
172 +
}
173 +
174 +
func promoteWine(db *sql.DB, shortID string) (bool, error) {
175 +
	res, err := db.Exec(`UPDATE wines SET wishlist = 0 WHERE short_id = ? AND wishlist = 1`, shortID)
176 +
	if err != nil {
177 +
		return false, err
178 +
	}
179 +
	n, _ := res.RowsAffected()
180 +
	return n > 0, nil
181 +
}
182 +
183 +
func updateWineImage(db *sql.DB, shortID string, image []byte, mime string) error {
184 +
	_, err := db.Exec(`UPDATE wines SET image = ?, image_mime = ? WHERE short_id = ?`, image, mime, shortID)
185 +
	return err
186 +
}
187 +
188 +
func deleteWine(db *sql.DB, shortID string) error {
189 +
	_, err := db.Exec(`DELETE FROM wines WHERE short_id = ?`, shortID)
190 +
	return err
191 +
}
192 +
193 +
func boolToInt(b bool) int {
194 +
	if b {
195 +
		return 1
196 +
	}
197 +
	return 0
198 +
}
apps/cellar-go/docker-compose.yml (added) +23 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/cellar-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - CELLAR_DB_PATH=/data/cellar-go.sqlite
12 +
      - CELLAR_PASSWORD=${CELLAR_PASSWORD:-changeme}
13 +
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
14 +
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
15 +
      - SITE_URL=${SITE_URL:-http://localhost:3000}
16 +
      - SITE_TITLE=${SITE_TITLE:-Cellar}
17 +
      - SITE_DESCRIPTION=${SITE_DESCRIPTION:-Personal wine tasting log}
18 +
    volumes:
19 +
      - cellar-go-data:/data
20 +
    restart: unless-stopped
21 +
22 +
volumes:
23 +
  cellar-go-data:
apps/cellar-go/forms.go (added) +141 −0
1 +
package main
2 +
3 +
import (
4 +
	"errors"
5 +
	"io"
6 +
	"mime/multipart"
7 +
	"net/http"
8 +
	"strconv"
9 +
	"strings"
10 +
)
11 +
12 +
const maxUploadBytes = 10 * 1024 * 1024
13 +
14 +
type wineFormData struct {
15 +
	Name       string
16 +
	Origin     string
17 +
	Grape      string
18 +
	Notes      string
19 +
	Background string
20 +
	Image      []byte
21 +
	ImageMime  string
22 +
23 +
	Sweetness      int
24 +
	Acidity        int
25 +
	Tannin         int
26 +
	Alcohol        int
27 +
	Body           int
28 +
	Clarity        int
29 +
	ColorIntensity int
30 +
	AromaIntensity int
31 +
	NoseComplexity int
32 +
}
33 +
34 +
func clamp1to5(v int) int {
35 +
	if v < 1 {
36 +
		return 1
37 +
	}
38 +
	if v > 5 {
39 +
		return 5
40 +
	}
41 +
	return v
42 +
}
43 +
44 +
func parseWineMultipart(r *http.Request) (*wineFormData, error) {
45 +
	r.Body = http.MaxBytesReader(nil, r.Body, maxUploadBytes)
46 +
	if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
47 +
		return nil, err
48 +
	}
49 +
	data := &wineFormData{
50 +
		Sweetness: 3, Acidity: 3, Tannin: 3, Alcohol: 3, Body: 3,
51 +
		Clarity: 3, ColorIntensity: 3, AromaIntensity: 3, NoseComplexity: 3,
52 +
	}
53 +
	data.Name = strings.TrimSpace(r.FormValue("name"))
54 +
	data.Origin = strings.TrimSpace(r.FormValue("origin"))
55 +
	data.Grape = strings.TrimSpace(r.FormValue("grape"))
56 +
	data.Notes = strings.TrimSpace(r.FormValue("notes"))
57 +
	data.Background = strings.TrimSpace(r.FormValue("background"))
58 +
59 +
	scoreFields := map[string]*int{
60 +
		"sweetness": &data.Sweetness, "acidity": &data.Acidity, "tannin": &data.Tannin,
61 +
		"alcohol": &data.Alcohol, "body": &data.Body,
62 +
		"clarity": &data.Clarity, "color_intensity": &data.ColorIntensity,
63 +
		"aroma_intensity": &data.AromaIntensity, "nose_complexity": &data.NoseComplexity,
64 +
	}
65 +
	for name, slot := range scoreFields {
66 +
		if v := r.FormValue(name); v != "" {
67 +
			if n, err := strconv.Atoi(v); err == nil {
68 +
				*slot = clamp1to5(n)
69 +
			}
70 +
		}
71 +
	}
72 +
73 +
	if data.Name == "" {
74 +
		return nil, errors.New("Name is required")
75 +
	}
76 +
	if err := readFormImage(r, data); err != nil {
77 +
		return nil, err
78 +
	}
79 +
	return data, nil
80 +
}
81 +
82 +
func parseWishlistMultipart(r *http.Request) (*wineFormData, error) {
83 +
	r.Body = http.MaxBytesReader(nil, r.Body, maxUploadBytes)
84 +
	if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
85 +
		return nil, err
86 +
	}
87 +
	data := &wineFormData{
88 +
		Sweetness: 3, Acidity: 3, Tannin: 3, Alcohol: 3, Body: 3,
89 +
		Clarity: 3, ColorIntensity: 3, AromaIntensity: 3, NoseComplexity: 3,
90 +
	}
91 +
	data.Name = strings.TrimSpace(r.FormValue("name"))
92 +
	data.Origin = strings.TrimSpace(r.FormValue("origin"))
93 +
	data.Grape = strings.TrimSpace(r.FormValue("grape"))
94 +
	data.Notes = strings.TrimSpace(r.FormValue("notes"))
95 +
	data.Background = strings.TrimSpace(r.FormValue("background"))
96 +
	if data.Name == "" {
97 +
		return nil, errors.New("Name is required")
98 +
	}
99 +
	if err := readFormImage(r, data); err != nil {
100 +
		return nil, err
101 +
	}
102 +
	return data, nil
103 +
}
104 +
105 +
func readFormImage(r *http.Request, data *wineFormData) error {
106 +
	file, _, err := r.FormFile("image")
107 +
	if err != nil {
108 +
		if errors.Is(err, http.ErrMissingFile) {
109 +
			return nil
110 +
		}
111 +
		return nil
112 +
	}
113 +
	defer file.Close()
114 +
	raw, err := io.ReadAll(file)
115 +
	if err != nil {
116 +
		return err
117 +
	}
118 +
	if len(raw) == 0 {
119 +
		return nil
120 +
	}
121 +
	processed, err := processImage(raw)
122 +
	if err != nil {
123 +
		return err
124 +
	}
125 +
	data.Image = processed
126 +
	data.ImageMime = "image/jpeg"
127 +
	return nil
128 +
}
129 +
130 +
func formToInput(f *wineFormData) WineInput {
131 +
	return WineInput{
132 +
		Name: f.Name, Origin: f.Origin, Grape: f.Grape, Notes: f.Notes,
133 +
		Background: f.Background,
134 +
		Sweetness:  f.Sweetness, Acidity: f.Acidity, Tannin: f.Tannin,
135 +
		Alcohol: f.Alcohol, Body: f.Body,
136 +
		Clarity: f.Clarity, ColorIntensity: f.ColorIntensity,
137 +
		AromaIntensity: f.AromaIntensity, NoseComplexity: f.NoseComplexity,
138 +
	}
139 +
}
140 +
141 +
var _ multipart.File = (multipart.File)(nil)
apps/cellar-go/go.mod (added) +32 −0
1 +
module github.com/stevedylandev/andromeda/apps/cellar-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 +
	modernc.org/sqlite v1.37.1
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 +
)
26 +
27 +
replace (
28 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
29 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
30 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
31 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
32 +
)
apps/cellar-go/go.sum (added) +49 −0
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=
apps/cellar-go/handlers_admin.go (added) +240 −0
1 +
package main
2 +
3 +
import (
4 +
	"io"
5 +
	"net/http"
6 +
	"net/url"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
9 +
	"github.com/stevedylandev/andromeda/crates-go/web"
10 +
)
11 +
12 +
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
13 +
	q := r.URL.Query()
14 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: q.Get("error"), Next: q.Get("next")}, a.Log)
15 +
}
16 +
17 +
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
18 +
	next := r.URL.Query().Get("next")
19 +
	if next == "" {
20 +
		next = "/admin"
21 +
	}
22 +
	if err := r.ParseForm(); err != nil {
23 +
		http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther)
24 +
		return
25 +
	}
26 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AppPassword) {
27 +
		http.Redirect(w, r, "/admin/login?error=Invalid+password&next="+url.QueryEscape(next), http.StatusSeeOther)
28 +
		return
29 +
	}
30 +
	token, err := a.Sessions.Create()
31 +
	if err != nil {
32 +
		a.Log.Error("create session failed", "err", err)
33 +
		http.Redirect(w, r, "/admin/login?error=Server+error", http.StatusSeeOther)
34 +
		return
35 +
	}
36 +
	a.Sessions.PruneExpired()
37 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
38 +
	target := "/admin"
39 +
	if len(next) > 0 && next[0] == '/' {
40 +
		target = next
41 +
	}
42 +
	http.Redirect(w, r, target, http.StatusSeeOther)
43 +
}
44 +
45 +
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
46 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
47 +
		a.Sessions.Delete(c.Value)
48 +
	}
49 +
	http.SetCookie(w, a.Sessions.ClearCookie())
50 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
51 +
}
52 +
53 +
func (a *App) adminIndex(w http.ResponseWriter, r *http.Request) {
54 +
	wines, err := getCellarWines(a.DB)
55 +
	if err != nil {
56 +
		a.Log.Error("list wines", "err", err)
57 +
		http.Error(w, "Server error", http.StatusInternalServerError)
58 +
		return
59 +
	}
60 +
	web.Render(a.Templates, w, "admin.html", adminPageData{Wines: wines}, a.Log)
61 +
}
62 +
63 +
func (a *App) newWineGet(w http.ResponseWriter, r *http.Request) {
64 +
	web.Render(a.Templates, w, "wine_form.html", wineFormPageData{Error: r.URL.Query().Get("error"), HasAnthropicKey: a.AnthropicAPIKey != ""}, a.Log)
65 +
}
66 +
67 +
func (a *App) editWineGet(w http.ResponseWriter, r *http.Request) {
68 +
	shortID := r.PathValue("short_id")
69 +
	wine, err := getWineByShortID(a.DB, shortID)
70 +
	if err != nil {
71 +
		http.Error(w, "Server error", http.StatusInternalServerError)
72 +
		return
73 +
	}
74 +
	if wine == nil {
75 +
		http.Error(w, "Wine not found", http.StatusNotFound)
76 +
		return
77 +
	}
78 +
	web.Render(a.Templates, w, "wine_form.html", wineFormPageData{Wine: wine, Error: r.URL.Query().Get("error"), HasAnthropicKey: a.AnthropicAPIKey != ""}, a.Log)
79 +
}
80 +
81 +
func (a *App) newWinePost(w http.ResponseWriter, r *http.Request) {
82 +
	data, err := parseWineMultipart(r)
83 +
	if err != nil {
84 +
		http.Redirect(w, r, "/admin/new?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
85 +
		return
86 +
	}
87 +
	wine, err := createWine(a.DB, formToInput(data), false)
88 +
	if err != nil {
89 +
		a.Log.Error("create wine", "err", err)
90 +
		http.Redirect(w, r, "/admin/new?error=Failed+to+create+wine", http.StatusSeeOther)
91 +
		return
92 +
	}
93 +
	if len(data.Image) > 0 {
94 +
		if err := updateWineImage(a.DB, wine.ShortID, data.Image, data.ImageMime); err != nil {
95 +
			a.Log.Error("set wine image", "err", err)
96 +
		}
97 +
	}
98 +
	http.Redirect(w, r, "/wines/"+wine.ShortID, http.StatusSeeOther)
99 +
}
100 +
101 +
func (a *App) editWinePost(w http.ResponseWriter, r *http.Request) {
102 +
	shortID := r.PathValue("short_id")
103 +
	data, err := parseWineMultipart(r)
104 +
	if err != nil {
105 +
		http.Redirect(w, r, "/admin/edit/"+shortID+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
106 +
		return
107 +
	}
108 +
	wine, err := updateWine(a.DB, shortID, formToInput(data))
109 +
	if err != nil {
110 +
		a.Log.Error("update wine", "err", err)
111 +
		http.Redirect(w, r, "/admin/edit/"+shortID+"?error=Failed+to+update+wine", http.StatusSeeOther)
112 +
		return
113 +
	}
114 +
	if wine == nil {
115 +
		http.Error(w, "Wine not found", http.StatusNotFound)
116 +
		return
117 +
	}
118 +
	if len(data.Image) > 0 {
119 +
		if err := updateWineImage(a.DB, shortID, data.Image, data.ImageMime); err != nil {
120 +
			a.Log.Error("update wine image", "err", err)
121 +
		}
122 +
	}
123 +
	http.Redirect(w, r, "/wines/"+shortID, http.StatusSeeOther)
124 +
}
125 +
126 +
func (a *App) deleteWinePost(w http.ResponseWriter, r *http.Request) {
127 +
	_ = deleteWine(a.DB, r.PathValue("short_id"))
128 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
129 +
}
130 +
131 +
func (a *App) newWishlistGet(w http.ResponseWriter, r *http.Request) {
132 +
	web.Render(a.Templates, w, "wishlist_form.html", wineFormPageData{Error: r.URL.Query().Get("error"), HasAnthropicKey: a.AnthropicAPIKey != ""}, a.Log)
133 +
}
134 +
135 +
func (a *App) editWishlistGet(w http.ResponseWriter, r *http.Request) {
136 +
	wine, err := getWineByShortID(a.DB, r.PathValue("short_id"))
137 +
	if err != nil {
138 +
		http.Error(w, "Server error", http.StatusInternalServerError)
139 +
		return
140 +
	}
141 +
	if wine == nil {
142 +
		http.Error(w, "Wine not found", http.StatusNotFound)
143 +
		return
144 +
	}
145 +
	web.Render(a.Templates, w, "wishlist_form.html", wineFormPageData{Wine: wine, Error: r.URL.Query().Get("error"), HasAnthropicKey: a.AnthropicAPIKey != ""}, a.Log)
146 +
}
147 +
148 +
func (a *App) newWishlistPost(w http.ResponseWriter, r *http.Request) {
149 +
	data, err := parseWishlistMultipart(r)
150 +
	if err != nil {
151 +
		http.Redirect(w, r, "/admin/wishlist/new?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
152 +
		return
153 +
	}
154 +
	wine, err := createWine(a.DB, formToInput(data), true)
155 +
	if err != nil {
156 +
		a.Log.Error("create wishlist wine", "err", err)
157 +
		http.Redirect(w, r, "/admin/wishlist/new?error=Failed+to+create+wine", http.StatusSeeOther)
158 +
		return
159 +
	}
160 +
	if len(data.Image) > 0 {
161 +
		_ = updateWineImage(a.DB, wine.ShortID, data.Image, data.ImageMime)
162 +
	}
163 +
	http.Redirect(w, r, "/wishlist", http.StatusSeeOther)
164 +
}
165 +
166 +
func (a *App) editWishlistPost(w http.ResponseWriter, r *http.Request) {
167 +
	shortID := r.PathValue("short_id")
168 +
	data, err := parseWishlistMultipart(r)
169 +
	if err != nil {
170 +
		http.Redirect(w, r, "/admin/wishlist/edit/"+shortID+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
171 +
		return
172 +
	}
173 +
	wine, err := updateWishlistWine(a.DB, shortID, data.Name, data.Origin, data.Grape, data.Notes, data.Background)
174 +
	if err != nil {
175 +
		a.Log.Error("update wishlist wine", "err", err)
176 +
		http.Redirect(w, r, "/admin/wishlist/edit/"+shortID+"?error=Failed+to+update+wine", http.StatusSeeOther)
177 +
		return
178 +
	}
179 +
	if wine == nil {
180 +
		http.Error(w, "Wine not found", http.StatusNotFound)
181 +
		return
182 +
	}
183 +
	if len(data.Image) > 0 {
184 +
		_ = updateWineImage(a.DB, shortID, data.Image, data.ImageMime)
185 +
	}
186 +
	http.Redirect(w, r, "/wishlist", http.StatusSeeOther)
187 +
}
188 +
189 +
func (a *App) deleteWishlistPost(w http.ResponseWriter, r *http.Request) {
190 +
	_ = deleteWine(a.DB, r.PathValue("short_id"))
191 +
	http.Redirect(w, r, "/wishlist", http.StatusSeeOther)
192 +
}
193 +
194 +
func (a *App) promoteWinePost(w http.ResponseWriter, r *http.Request) {
195 +
	shortID := r.PathValue("short_id")
196 +
	ok, err := promoteWine(a.DB, shortID)
197 +
	if err != nil {
198 +
		http.Redirect(w, r, "/wishlist", http.StatusSeeOther)
199 +
		return
200 +
	}
201 +
	if !ok {
202 +
		http.Error(w, "Wine not found", http.StatusNotFound)
203 +
		return
204 +
	}
205 +
	http.Redirect(w, r, "/admin/edit/"+shortID, http.StatusSeeOther)
206 +
}
207 +
208 +
func (a *App) analyzeImage(w http.ResponseWriter, r *http.Request) {
209 +
	if a.AnthropicAPIKey == "" {
210 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "No API key configured"})
211 +
		return
212 +
	}
213 +
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
214 +
	if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
215 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
216 +
		return
217 +
	}
218 +
	file, header, err := r.FormFile("image")
219 +
	if err != nil {
220 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "No image provided"})
221 +
		return
222 +
	}
223 +
	defer file.Close()
224 +
	raw, err := io.ReadAll(file)
225 +
	if err != nil || len(raw) == 0 {
226 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "No image provided"})
227 +
		return
228 +
	}
229 +
	mediaType := "image/jpeg"
230 +
	if header != nil && header.Header.Get("Content-Type") != "" {
231 +
		mediaType = header.Header.Get("Content-Type")
232 +
	}
233 +
	result, err := analyzeWineImage(r.Context(), a.AnthropicAPIKey, raw, mediaType)
234 +
	if err != nil {
235 +
		a.Log.Error("Claude analysis failed", "err", err)
236 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
237 +
		return
238 +
	}
239 +
	web.WriteJSON(w, http.StatusOK, result)
240 +
}
apps/cellar-go/handlers_api.go (added) +60 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/web"
7 +
)
8 +
9 +
func (a *App) apiListWines(w http.ResponseWriter, r *http.Request) {
10 +
	wines, err := getCellarWines(a.DB)
11 +
	if err != nil {
12 +
		a.Log.Error("api list wines", "err", err)
13 +
		w.WriteHeader(http.StatusInternalServerError)
14 +
		return
15 +
	}
16 +
	web.WriteJSON(w, http.StatusOK, wines)
17 +
}
18 +
19 +
func (a *App) apiGetWine(w http.ResponseWriter, r *http.Request) {
20 +
	wine, err := getWineByShortID(a.DB, r.PathValue("short_id"))
21 +
	if err != nil {
22 +
		w.WriteHeader(http.StatusInternalServerError)
23 +
		return
24 +
	}
25 +
	if wine == nil {
26 +
		http.NotFound(w, r)
27 +
		return
28 +
	}
29 +
	web.WriteJSON(w, http.StatusOK, wine)
30 +
}
31 +
32 +
func (a *App) apiPentagonSVG(w http.ResponseWriter, r *http.Request) {
33 +
	wine, err := getWineByShortID(a.DB, r.PathValue("short_id"))
34 +
	if err != nil {
35 +
		w.WriteHeader(http.StatusInternalServerError)
36 +
		return
37 +
	}
38 +
	if wine == nil {
39 +
		http.NotFound(w, r)
40 +
		return
41 +
	}
42 +
	svg := buildPentagonSVG(wine.Sweetness, wine.Acidity, wine.Tannin, wine.Alcohol, wine.Body, 250.0, true)
43 +
	w.Header().Set("Content-Type", "image/svg+xml")
44 +
	_, _ = w.Write([]byte(svg))
45 +
}
46 +
47 +
func (a *App) apiBarsSVG(w http.ResponseWriter, r *http.Request) {
48 +
	wine, err := getWineByShortID(a.DB, r.PathValue("short_id"))
49 +
	if err != nil {
50 +
		w.WriteHeader(http.StatusInternalServerError)
51 +
		return
52 +
	}
53 +
	if wine == nil {
54 +
		http.NotFound(w, r)
55 +
		return
56 +
	}
57 +
	svg := buildBarsSVG(wine.Clarity, wine.ColorIntensity, wine.AromaIntensity, wine.NoseComplexity, 250.0)
58 +
	w.Header().Set("Content-Type", "image/svg+xml")
59 +
	_, _ = w.Write([]byte(svg))
60 +
}
apps/cellar-go/handlers_public.go (added) +124 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"net/http"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/web"
10 +
)
11 +
12 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
13 +
	wines, err := getCellarWines(a.DB)
14 +
	if err != nil {
15 +
		a.Log.Error("list wines", "err", err)
16 +
		http.Error(w, "Server error", http.StatusInternalServerError)
17 +
		return
18 +
	}
19 +
	out := make([]wineWithSVG, 0, len(wines))
20 +
	for _, wine := range wines {
21 +
		svg := buildPentagonSVG(wine.Sweetness, wine.Acidity, wine.Tannin, wine.Alcohol, wine.Body, 80.0, false)
22 +
		out = append(out, wineWithSVG{Wine: wine, PentagonSVG: template.HTML(svg)})
23 +
	}
24 +
	web.Render(a.Templates, w, "index.html", indexPageData{Wines: out}, a.Log)
25 +
}
26 +
27 +
func (a *App) wineDetail(w http.ResponseWriter, r *http.Request) {
28 +
	wine, err := getWineByShortID(a.DB, r.PathValue("short_id"))
29 +
	if err != nil {
30 +
		http.Error(w, "Server error", http.StatusInternalServerError)
31 +
		return
32 +
	}
33 +
	if wine == nil {
34 +
		http.Error(w, "Wine not found", http.StatusNotFound)
35 +
		return
36 +
	}
37 +
	pentagon := buildPentagonSVG(wine.Sweetness, wine.Acidity, wine.Tannin, wine.Alcohol, wine.Body, 250.0, true)
38 +
	bars := buildBarsSVG(wine.Clarity, wine.ColorIntensity, wine.AromaIntensity, wine.NoseComplexity, 250.0)
39 +
	web.Render(a.Templates, w, "wine.html", wineDetailPageData{Wine: *wine, PentagonSVG: template.HTML(pentagon), BarsSVG: template.HTML(bars)}, a.Log)
40 +
}
41 +
42 +
func (a *App) wineImage(w http.ResponseWriter, r *http.Request) {
43 +
	bytes, mime, err := getWineImage(a.DB, r.PathValue("short_id"))
44 +
	if err != nil {
45 +
		http.Error(w, "Server error", http.StatusInternalServerError)
46 +
		return
47 +
	}
48 +
	if bytes == nil {
49 +
		http.NotFound(w, r)
50 +
		return
51 +
	}
52 +
	if mime == "" {
53 +
		mime = "application/octet-stream"
54 +
	}
55 +
	w.Header().Set("Content-Type", mime)
56 +
	_, _ = w.Write(bytes)
57 +
}
58 +
59 +
func (a *App) wishlistHandler(w http.ResponseWriter, r *http.Request) {
60 +
	wines, err := getWishlistWines(a.DB)
61 +
	if err != nil {
62 +
		http.Error(w, "Server error", http.StatusInternalServerError)
63 +
		return
64 +
	}
65 +
	web.Render(a.Templates, w, "wishlist.html", wishlistPageData{Wines: wines, IsAdmin: a.Sessions.HasValid(r)}, a.Log)
66 +
}
67 +
68 +
func xmlEscape(s string) string {
69 +
	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
70 +
	return r.Replace(s)
71 +
}
72 +
73 +
func toRFC2822(sqliteTS string) string {
74 +
	if t, err := time.Parse("2006-01-02 15:04:05", sqliteTS); err == nil {
75 +
		return t.UTC().Format(time.RFC1123Z)
76 +
	}
77 +
	return sqliteTS
78 +
}
79 +
80 +
func (a *App) rssFeed(w http.ResponseWriter, r *http.Request) {
81 +
	wines, err := getCellarWines(a.DB)
82 +
	if err != nil {
83 +
		http.Error(w, "Server error", http.StatusInternalServerError)
84 +
		return
85 +
	}
86 +
	siteURL := a.SiteURL
87 +
88 +
	var items strings.Builder
89 +
	for _, wine := range wines {
90 +
		link := siteURL + "/wines/" + xmlEscape(wine.ShortID)
91 +
		title := xmlEscape(wine.Name)
92 +
		var parts []string
93 +
		if wine.Origin != "" {
94 +
			parts = append(parts, "Origin: "+wine.Origin)
95 +
		}
96 +
		if wine.Grape != "" {
97 +
			parts = append(parts, "Grape: "+wine.Grape)
98 +
		}
99 +
		if wine.Notes != "" {
100 +
			parts = append(parts, wine.Notes)
101 +
		}
102 +
		desc := xmlEscape(strings.Join(parts, " — "))
103 +
		pub := toRFC2822(wine.CreatedAt)
104 +
		items.WriteString("    <item>\n      <title>" + title + "</title>\n      <link>" + link + "</link>\n      <guid>" + link + "</guid>\n      <description>" + desc + "</description>\n      <pubDate>" + pub + "</pubDate>\n    </item>\n")
105 +
	}
106 +
107 +
	lastBuild := ""
108 +
	if len(wines) > 0 {
109 +
		lastBuild = toRFC2822(wines[0].CreatedAt)
110 +
	}
111 +
112 +
	out := `<?xml version="1.0" encoding="UTF-8"?>
113 +
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
114 +
  <channel>
115 +
    <title>` + xmlEscape(a.SiteTitle) + `</title>
116 +
    <link>` + siteURL + `</link>
117 +
    <description>` + xmlEscape(a.SiteDescription) + `</description>
118 +
    <lastBuildDate>` + lastBuild + `</lastBuildDate>
119 +
    <atom:link href="` + siteURL + `/feed.xml" rel="self" type="application/rss+xml"/>
120 +
` + items.String() + `  </channel>
121 +
</rss>`
122 +
	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
123 +
	_, _ = w.Write([]byte(out))
124 +
}
apps/cellar-go/image.go (added) +21 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"fmt"
6 +
	"image"
7 +
	"image/jpeg"
8 +
	_ "image/png"
9 +
)
10 +
11 +
func processImage(data []byte) ([]byte, error) {
12 +
	img, _, err := image.Decode(bytes.NewReader(data))
13 +
	if err != nil {
14 +
		return nil, fmt.Errorf("Failed to decode image: %w", err)
15 +
	}
16 +
	var out bytes.Buffer
17 +
	if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 75}); err != nil {
18 +
		return nil, fmt.Errorf("JPEG encoding failed: %w", err)
19 +
	}
20 +
	return out.Bytes(), nil
21 +
}
apps/cellar-go/main.go (added) +57 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
	"strings"
10 +
11 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 +
	"github.com/stevedylandev/andromeda/crates-go/config"
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("CELLAR_DB_PATH", "cellar.sqlite")
20 +
	db, err := openDB(dbPath)
21 +
	if err != nil {
22 +
		log.Fatal(err)
23 +
	}
24 +
	defer db.Close()
25 +
26 +
	password := os.Getenv("CELLAR_PASSWORD")
27 +
	if password == "" {
28 +
		logger.Warn("CELLAR_PASSWORD not set, using default 'changeme'")
29 +
		password = "changeme"
30 +
	}
31 +
32 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
33 +
	if err := sessions.EnsureSchema(); err != nil {
34 +
		log.Fatal(err)
35 +
	}
36 +
	sessions.PruneExpired()
37 +
38 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
39 +
	app := &App{
40 +
		DB:              db,
41 +
		Log:             logger,
42 +
		Templates:       tmpl,
43 +
		Sessions:        sessions,
44 +
		AppPassword:     password,
45 +
		CookieSecure:    sessions.CookieSecure,
46 +
		AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
47 +
		SiteURL:         strings.TrimRight(config.Getenv("SITE_URL", "http://localhost:3000"), "/"),
48 +
		SiteTitle:       config.Getenv("SITE_TITLE", "Cellar"),
49 +
		SiteDescription: config.Getenv("SITE_DESCRIPTION", "Personal wine tasting log"),
50 +
	}
51 +
52 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
53 +
	logger.Info("cellar-go server running", "addr", addr)
54 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
55 +
		log.Fatal(err)
56 +
	}
57 +
}
apps/cellar-go/routes.go (added) +61 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/crates-go/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 +
	cors := func(next http.HandlerFunc) http.HandlerFunc {
17 +
		return func(w http.ResponseWriter, r *http.Request) {
18 +
			w.Header().Set("Access-Control-Allow-Origin", "*")
19 +
			w.Header().Set("Access-Control-Allow-Methods", "GET")
20 +
			next(w, r)
21 +
		}
22 +
	}
23 +
24 +
	// Public
25 +
	mux.HandleFunc("GET /", a.indexHandler)
26 +
	mux.HandleFunc("GET /feed.xml", a.rssFeed)
27 +
	mux.HandleFunc("GET /wines/{short_id}", a.wineDetail)
28 +
	mux.HandleFunc("GET /wines/{short_id}/image", a.wineImage)
29 +
	mux.HandleFunc("GET /wishlist", a.wishlistHandler)
30 +
31 +
	// API
32 +
	mux.HandleFunc("GET /api/wines", cors(a.apiListWines))
33 +
	mux.HandleFunc("GET /api/wines/{short_id}", cors(a.apiGetWine))
34 +
	mux.HandleFunc("GET /api/wines/{short_id}/pentagon.svg", cors(a.apiPentagonSVG))
35 +
	mux.HandleFunc("GET /api/wines/{short_id}/bars.svg", cors(a.apiBarsSVG))
36 +
37 +
	// Admin auth
38 +
	mux.HandleFunc("GET /admin/login", a.loginGet)
39 +
	mux.HandleFunc("POST /admin/login", a.loginPost)
40 +
	mux.HandleFunc("GET /admin/logout", a.logout)
41 +
42 +
	// Admin protected
43 +
	mux.HandleFunc("GET /admin", requireSession(a.adminIndex))
44 +
	mux.HandleFunc("GET /admin/new", requireSession(a.newWineGet))
45 +
	mux.HandleFunc("POST /admin/new", requireSession(a.newWinePost))
46 +
	mux.HandleFunc("GET /admin/edit/{short_id}", requireSession(a.editWineGet))
47 +
	mux.HandleFunc("POST /admin/edit/{short_id}", requireSession(a.editWinePost))
48 +
	mux.HandleFunc("POST /admin/delete/{short_id}", requireSession(a.deleteWinePost))
49 +
	mux.HandleFunc("GET /admin/wishlist/new", requireSession(a.newWishlistGet))
50 +
	mux.HandleFunc("POST /admin/wishlist/new", requireSession(a.newWishlistPost))
51 +
	mux.HandleFunc("GET /admin/wishlist/edit/{short_id}", requireSession(a.editWishlistGet))
52 +
	mux.HandleFunc("POST /admin/wishlist/edit/{short_id}", requireSession(a.editWishlistPost))
53 +
	mux.HandleFunc("POST /admin/wishlist/delete/{short_id}", requireSession(a.deleteWishlistPost))
54 +
	mux.HandleFunc("POST /admin/wishlist/promote/{short_id}", requireSession(a.promoteWinePost))
55 +
	mux.HandleFunc("POST /admin/analyze-image", requireSession(a.analyzeImage))
56 +
57 +
	// Static
58 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
59 +
	darkmatter.Mount(mux, "/assets")
60 +
	return mux
61 +
}
apps/cellar-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/cellar-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/cellar-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/cellar-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/cellar-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/cellar-go/static/styles.css (added) +250 −0
1 +
/* cellar — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
textarea {
6 +
  min-height: 120px;
7 +
}
8 +
9 +
input[type="file"] {
10 +
  border: none;
11 +
  padding: 0;
12 +
  font-size: 12px;
13 +
}
14 +
15 +
button:disabled {
16 +
  opacity: 0.3;
17 +
  cursor: not-allowed;
18 +
}
19 +
20 +
/* Wine list (public) */
21 +
22 +
.wine-list {
23 +
  display: flex;
24 +
  flex-direction: column;
25 +
  width: 100%;
26 +
}
27 +
28 +
.wine-card {
29 +
  display: flex;
30 +
  align-items: center;
31 +
  gap: 1rem;
32 +
  padding: 12px 0;
33 +
  border-bottom: 1px solid #333;
34 +
  text-decoration: none;
35 +
}
36 +
37 +
.wine-card:hover {
38 +
  opacity: 0.7;
39 +
}
40 +
41 +
.wine-pentagon {
42 +
  flex-shrink: 0;
43 +
  width: 80px;
44 +
  height: 80px;
45 +
}
46 +
47 +
.wine-info {
48 +
  display: flex;
49 +
  flex-direction: column;
50 +
  gap: 2px;
51 +
}
52 +
53 +
.wine-name {
54 +
  font-size: 16px;
55 +
}
56 +
57 +
.wine-meta {
58 +
  font-size: 12px;
59 +
  opacity: 0.5;
60 +
}
61 +
62 +
/* Wine detail (public) */
63 +
64 +
.wine-detail {
65 +
  display: flex;
66 +
  flex-direction: column;
67 +
  gap: 1.5rem;
68 +
  width: 100%;
69 +
  padding-bottom: 4rem;
70 +
}
71 +
72 +
.wine-detail-top {
73 +
  display: grid;
74 +
  grid-template-columns: 1fr 1fr;
75 +
  gap: 1.5rem;
76 +
  align-items: center;
77 +
}
78 +
79 +
@media (max-width: 480px) {
80 +
  .wine-detail-top {
81 +
    grid-template-columns: 1fr;
82 +
  }
83 +
  .wine-image {
84 +
    max-height: none;
85 +
    width: 100%;
86 +
  }
87 +
}
88 +
89 +
.wine-image-wrap {
90 +
  width: 100%;
91 +
}
92 +
93 +
.wine-image {
94 +
  width: 100%;
95 +
  object-fit: cover;
96 +
  border-radius: 4px;
97 +
}
98 +
99 +
.wine-detail-name {
100 +
  font-size: 24px;
101 +
  font-weight: 700;
102 +
  letter-spacing: -0.5px;
103 +
}
104 +
105 +
.wine-detail-meta {
106 +
  display: flex;
107 +
  flex-direction: column;
108 +
  gap: 0.25rem;
109 +
}
110 +
111 +
.meta-row {
112 +
  display: flex;
113 +
  gap: 0.75rem;
114 +
  font-size: 14px;
115 +
}
116 +
117 +
.meta-label {
118 +
  font-size: 12px;
119 +
  opacity: 0.5;
120 +
}
121 +
122 +
.wine-detail-chart {
123 +
  display: flex;
124 +
  flex-direction: column;
125 +
  align-items: center;
126 +
  gap: 1rem;
127 +
  padding: 0.75rem;
128 +
}
129 +
130 +
.wine-detail-notes {
131 +
  display: flex;
132 +
  flex-direction: column;
133 +
  gap: 0.25rem;
134 +
}
135 +
136 +
.wine-detail-notes p {
137 +
  white-space: pre-wrap;
138 +
}
139 +
140 +
/* Admin list */
141 +
142 +
.admin-list {
143 +
  display: flex;
144 +
  flex-direction: column;
145 +
  width: 100%;
146 +
}
147 +
148 +
.admin-item {
149 +
  display: flex;
150 +
  justify-content: space-between;
151 +
  align-items: center;
152 +
  padding: 8px 0;
153 +
  border-bottom: 1px solid #333;
154 +
}
155 +
156 +
.admin-item-info {
157 +
  display: flex;
158 +
  flex-direction: column;
159 +
  gap: 2px;
160 +
}
161 +
162 +
.admin-item-name {
163 +
  font-size: 16px;
164 +
}
165 +
166 +
.admin-item-meta {
167 +
  font-size: 12px;
168 +
  opacity: 0.5;
169 +
}
170 +
171 +
.admin-actions {
172 +
  display: flex;
173 +
  gap: 1rem;
174 +
  font-size: 12px;
175 +
}
176 +
177 +
/* Score inputs */
178 +
179 +
.image-upload-row {
180 +
  display: flex;
181 +
  align-items: center;
182 +
  gap: 0.75rem;
183 +
}
184 +
185 +
.score-group {
186 +
  display: flex;
187 +
  flex-direction: column;
188 +
  gap: 0.5rem;
189 +
  margin-top: 0.5rem;
190 +
}
191 +
192 +
.score-section-label {
193 +
  font-size: 11px;
194 +
  opacity: 0.4;
195 +
  text-transform: uppercase;
196 +
  letter-spacing: 1px;
197 +
  margin-top: 0.75rem;
198 +
}
199 +
200 +
.score-section-label:first-child {
201 +
  margin-top: 0;
202 +
}
203 +
204 +
.score-row {
205 +
  display: flex;
206 +
  align-items: center;
207 +
  gap: 0.75rem;
208 +
}
209 +
210 +
.score-row label {
211 +
  width: 80px;
212 +
  flex-shrink: 0;
213 +
}
214 +
215 +
.score-row input[type="range"] {
216 +
  flex: 1;
217 +
  -webkit-appearance: none;
218 +
  appearance: none;
219 +
  height: 2px;
220 +
  background: #555;
221 +
  border: none;
222 +
  padding: 0;
223 +
}
224 +
225 +
.score-row input[type="range"]::-webkit-slider-thumb {
226 +
  -webkit-appearance: none;
227 +
  appearance: none;
228 +
  width: 14px;
229 +
  height: 14px;
230 +
  background: #ffffff;
231 +
  border: none;
232 +
  border-radius: 0;
233 +
  cursor: pointer;
234 +
}
235 +
236 +
.score-row input[type="range"]::-moz-range-thumb {
237 +
  width: 14px;
238 +
  height: 14px;
239 +
  background: #ffffff;
240 +
  border: none;
241 +
  border-radius: 0;
242 +
  cursor: pointer;
243 +
}
244 +
245 +
.score-value {
246 +
  width: 16px;
247 +
  text-align: center;
248 +
  font-size: 12px;
249 +
  opacity: 0.7;
250 +
}
apps/cellar-go/svg.go (added) +125 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"math"
6 +
	"strings"
7 +
)
8 +
9 +
func buildPentagonSVG(sweetness, acidity, tannin, alcohol, body int, size float64, showLabels bool) string {
10 +
	cx, cy := size/2.0, size/2.0
11 +
	margin := 5.0
12 +
	if showLabels {
13 +
		margin = 30.0
14 +
	}
15 +
	r := size/2.0 - margin
16 +
	scores := []int{sweetness, acidity, tannin, alcohol, body}
17 +
	labels := []string{"Sweetness", "Acidity", "Tannin", "Alcohol", "Body"}
18 +
	angles := make([]float64, 5)
19 +
	for i := range angles {
20 +
		angles[i] = (-90.0 + 72.0*float64(i)) * math.Pi / 180.0
21 +
	}
22 +
23 +
	var b strings.Builder
24 +
	fmt.Fprintf(&b, `<svg viewBox="0 0 %g %g" width="100%%" xmlns="http://www.w3.org/2000/svg">`, size, size)
25 +
26 +
	for _, pct := range []float64{0.2, 0.4, 0.6, 0.8} {
27 +
		parts := make([]string, 5)
28 +
		for i, a := range angles {
29 +
			parts[i] = fmt.Sprintf("%.1f,%.1f", cx+r*pct*math.Cos(a), cy+r*pct*math.Sin(a))
30 +
		}
31 +
		fmt.Fprintf(&b, `<polygon points="%s" fill="none" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>`, strings.Join(parts, " "))
32 +
	}
33 +
34 +
	outline := make([]string, 5)
35 +
	for i, a := range angles {
36 +
		outline[i] = fmt.Sprintf("%.1f,%.1f", cx+r*math.Cos(a), cy+r*math.Sin(a))
37 +
	}
38 +
	fmt.Fprintf(&b, `<polygon points="%s" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="1"/>`, strings.Join(outline, " "))
39 +
40 +
	for _, a := range angles {
41 +
		fmt.Fprintf(&b, `<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>`,
42 +
			cx, cy, cx+r*math.Cos(a), cy+r*math.Sin(a))
43 +
	}
44 +
45 +
	dataPoints := make([][2]float64, 5)
46 +
	for i, s := range scores {
47 +
		d := float64(s) / 5.0 * r
48 +
		dataPoints[i] = [2]float64{cx + d*math.Cos(angles[i]), cy + d*math.Sin(angles[i])}
49 +
	}
50 +
	parts := make([]string, 5)
51 +
	for i, p := range dataPoints {
52 +
		parts[i] = fmt.Sprintf("%.1f,%.1f", p[0], p[1])
53 +
	}
54 +
	fmt.Fprintf(&b, `<polygon points="%s" fill="white" fill-opacity="0.08" stroke="white" stroke-width="1.5"/>`, strings.Join(parts, " "))
55 +
56 +
	for _, p := range dataPoints {
57 +
		fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="2.5" fill="white"/>`, p[0], p[1])
58 +
	}
59 +
60 +
	if showLabels {
61 +
		for i, label := range labels {
62 +
			a := angles[i]
63 +
			labelDist := r + 18.0
64 +
			lx := cx + labelDist*math.Cos(a)
65 +
			ly := cy + labelDist*math.Sin(a) + 3.5
66 +
			fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace" text-anchor="middle">%s</text>`, lx, ly, label)
67 +
		}
68 +
	}
69 +
	b.WriteString("</svg>")
70 +
	return b.String()
71 +
}
72 +
73 +
func buildBarsSVG(clarity, colorIntensity, aromaIntensity, noseComplexity int, width float64) string {
74 +
	barHeight := 4.0
75 +
	rowHeight := 22.0
76 +
	sectionGap := 14.0
77 +
	labelWidth := 100.0
78 +
	trackLeft := labelWidth + 4.0
79 +
	trackWidth := width - trackLeft - 10.0
80 +
	headerSize := 9.0
81 +
82 +
	type attr struct {
83 +
		Label string
84 +
		Score int
85 +
	}
86 +
	sections := []struct {
87 +
		Name  string
88 +
		Attrs []attr
89 +
	}{
90 +
		{"Appearance", []attr{{"Clarity", clarity}, {"Intensity", colorIntensity}}},
91 +
		{"Nose", []attr{{"Aroma", aromaIntensity}, {"Complexity", noseComplexity}}},
92 +
	}
93 +
	totalRows := 0
94 +
	for _, s := range sections {
95 +
		totalRows += len(s.Attrs)
96 +
	}
97 +
	totalHeight := float64(len(sections))*(headerSize+8.0) + float64(totalRows)*rowHeight + sectionGap
98 +
99 +
	var b strings.Builder
100 +
	fmt.Fprintf(&b, `<svg viewBox="0 0 %g %g" width="100%%" xmlns="http://www.w3.org/2000/svg">`, width, totalHeight)
101 +
	y := 4.0
102 +
	for si, sec := range sections {
103 +
		if si > 0 {
104 +
			y += sectionGap
105 +
		}
106 +
		fmt.Fprintf(&b, `<text x="0" y="%.1f" fill="white" fill-opacity="0.4" font-size="%g" font-family="Commit Mono, monospace" text-transform="uppercase" letter-spacing="1">%s</text>`,
107 +
			y+headerSize, headerSize, sec.Name)
108 +
		y += headerSize + 8.0
109 +
		for _, a := range sec.Attrs {
110 +
			barY := y + (rowHeight-barHeight)/2.0
111 +
			fillW := float64(a.Score) / 5.0 * trackWidth
112 +
			fmt.Fprintf(&b, `<text x="0" y="%.1f" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace">%s</text>`,
113 +
				y+rowHeight/2.0+3.0, a.Label)
114 +
			fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="2" fill="white" fill-opacity="0.08"/>`,
115 +
				trackLeft, barY, trackWidth, barHeight)
116 +
			if fillW > 0 {
117 +
				fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="2" fill="white" fill-opacity="0.6"/>`,
118 +
					trackLeft, barY, fillW, barHeight)
119 +
			}
120 +
			y += rowHeight
121 +
		}
122 +
	}
123 +
	b.WriteString("</svg>")
124 +
	return b.String()
125 +
}
apps/cellar-go/templates/admin.html (added) +27 −0
1 +
{{define "admin.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Admin - Cellar{{end}}
3 +
{{define "nav"}}
4 +
<nav class="links">
5 +
  <a href="/admin/new">new</a>
6 +
  <a href="/wishlist">wishlist</a>
7 +
</nav>
8 +
{{end}}
9 +
{{define "content"}}
10 +
  {{if not .Wines}}<p class="empty">no wines yet</p>{{end}}
11 +
  <div class="admin-list">
12 +
    {{range .Wines}}
13 +
    <div class="admin-item">
14 +
      <div class="admin-item-info">
15 +
        <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a>
16 +
        <span class="admin-item-meta">{{.Origin}}{{if .Grape}} &middot; {{.Grape}}{{end}}</span>
17 +
      </div>
18 +
      <div class="admin-actions">
19 +
        <a href="/admin/edit/{{.ShortID}}">edit</a>
20 +
        <form method="POST" action="/admin/delete/{{.ShortID}}" class="inline-form" onsubmit="return confirm('delete this wine?')">
21 +
          <button type="submit" class="link-button">delete</button>
22 +
        </form>
23 +
      </div>
24 +
    </div>
25 +
    {{end}}
26 +
  </div>
27 +
{{end}}
apps/cellar-go/templates/base.html (added) +29 −0
1 +
{{define "base.html"}}<!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 +
  <title>{{block "title" .}}Cellar{{end}}</title>
7 +
  <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
  <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
  <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
  <link rel="manifest" href="/static/site.webmanifest">
11 +
  <link rel="icon" href="/static/favicon.ico">
12 +
  <meta property="og:title" content="Cellar">
13 +
  <meta property="og:image" content="/static/og.png">
14 +
  <meta property="og:type" content="website">
15 +
  <meta name="theme-color" content="#121113" />
16 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
17 +
  <link rel="stylesheet" href="/static/styles.css">
18 +
  <link rel="alternate" type="application/rss+xml" title="Cellar RSS" href="/feed.xml">
19 +
</head>
20 +
<body>
21 +
  <header class="header">
22 +
    <a href="/" class="logo">cellar</a>
23 +
    {{block "nav" .}}{{end}}
24 +
  </header>
25 +
  <main>
26 +
    {{block "content" .}}{{end}}
27 +
  </main>
28 +
</body>
29 +
</html>{{end}}
apps/cellar-go/templates/index.html (added) +26 −0
1 +
{{define "index.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Cellar{{end}}
3 +
{{define "nav"}}
4 +
<nav class="links">
5 +
  <a href="/admin/new">new</a>
6 +
  <a href="/wishlist">wishlist</a>
7 +
</nav>
8 +
{{end}}
9 +
{{define "content"}}
10 +
  {{if not .Wines}}
11 +
    <p class="empty">no wines yet</p>
12 +
  {{end}}
13 +
  <div class="wine-list">
14 +
    {{range .Wines}}
15 +
    <a href="/wines/{{.Wine.ShortID}}" class="wine-card">
16 +
      <div class="wine-pentagon">
17 +
        {{.PentagonSVG}}
18 +
      </div>
19 +
      <div class="wine-info">
20 +
        <span class="wine-name">{{.Wine.Name}}</span>
21 +
        <span class="wine-meta">{{.Wine.Origin}}{{if .Wine.Grape}} &middot; {{.Wine.Grape}}{{end}}</span>
22 +
      </div>
23 +
    </a>
24 +
    {{end}}
25 +
  </div>
26 +
{{end}}
apps/cellar-go/templates/login.html (added) +24 −0
1 +
{{define "login.html"}}<!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 +
  <title>Cellar</title>
7 +
  <meta name="theme-color" content="#121113" />
8 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
9 +
  <link rel="stylesheet" href="/static/styles.css">
10 +
</head>
11 +
<body>
12 +
  <header class="header">
13 +
    <span class="logo">CELLAR</span>
14 +
  </header>
15 +
  <main>
16 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
17 +
    <form method="POST" action="/admin/login{{if .Next}}?next={{.Next}}{{end}}" class="form">
18 +
      <label for="password">password</label>
19 +
      <input type="password" id="password" name="password" autofocus required>
20 +
      <button type="submit">login</button>
21 +
    </form>
22 +
  </main>
23 +
</body>
24 +
</html>{{end}}
apps/cellar-go/templates/wine.html (added) +31 −0
1 +
{{define "wine.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Wine.Name}} - Cellar{{end}}
3 +
{{define "nav"}}
4 +
<nav class="links">
5 +
  <a href="/admin/edit/{{.Wine.ShortID}}">edit</a>
6 +
</nav>
7 +
{{end}}
8 +
{{define "content"}}
9 +
  <div class="wine-detail">
10 +
    <h1 class="wine-detail-name">{{.Wine.Name}}</h1>
11 +
    <div class="wine-detail-top">
12 +
      {{if .Wine.HasImage}}
13 +
      <div class="wine-image-wrap">
14 +
        <img src="/wines/{{.Wine.ShortID}}/image" alt="{{.Wine.Name}}" class="wine-image">
15 +
      </div>
16 +
      {{end}}
17 +
      {{if not .Wine.Wishlist}}
18 +
      <div class="wine-detail-chart">
19 +
        {{.PentagonSVG}}
20 +
        {{.BarsSVG}}
21 +
      </div>
22 +
      {{end}}
23 +
    </div>
24 +
    <div class="wine-detail-meta">
25 +
      {{if .Wine.Origin}}<div class="meta-row"><span class="meta-label">origin</span><span>{{.Wine.Origin}}</span></div>{{end}}
26 +
      {{if .Wine.Grape}}<div class="meta-row"><span class="meta-label">grape</span><span>{{.Wine.Grape}}</span></div>{{end}}
27 +
    </div>
28 +
    {{if .Wine.Notes}}<div class="wine-detail-notes"><span class="meta-label">notes</span><p>{{.Wine.Notes}}</p></div>{{end}}
29 +
    {{if .Wine.Background}}<div class="wine-detail-notes"><span class="meta-label">background</span><p>{{.Wine.Background}}</p></div>{{end}}
30 +
  </div>
31 +
{{end}}
apps/cellar-go/templates/wine_form.html (added) +118 −0
1 +
{{define "wine_form.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{if .Wine}}Edit{{else}}New{{end}} Wine - Cellar{{end}}
3 +
{{define "content"}}
4 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5 +
  {{$w := .Wine}}
6 +
  <form method="POST" enctype="multipart/form-data"
7 +
    action="{{if $w}}/admin/edit/{{$w.ShortID}}{{else}}/admin/new{{end}}"
8 +
    class="form">
9 +
10 +
    <label for="image">image</label>
11 +
    <div class="image-upload-row">
12 +
      <input type="file" id="image" name="image" accept="image/*">
13 +
      {{if .HasAnthropicKey}}<button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>{{end}}
14 +
    </div>
15 +
16 +
    <label for="name">name</label>
17 +
    <input type="text" id="name" name="name" required value="{{if $w}}{{$w.Name}}{{end}}">
18 +
19 +
    <label for="origin">origin</label>
20 +
    <input type="text" id="origin" name="origin" value="{{if $w}}{{$w.Origin}}{{end}}">
21 +
22 +
    <label for="grape">grape</label>
23 +
    <input type="text" id="grape" name="grape" value="{{if $w}}{{$w.Grape}}{{end}}">
24 +
25 +
    <label for="notes">notes</label>
26 +
    <textarea id="notes" name="notes" rows="5">{{if $w}}{{$w.Notes}}{{end}}</textarea>
27 +
28 +
    <label for="background">background</label>
29 +
    <textarea id="background" name="background" rows="5">{{if $w}}{{$w.Background}}{{end}}</textarea>
30 +
31 +
    <div class="score-group">
32 +
      <div class="score-section-label">appearance</div>
33 +
      <div class="score-row">
34 +
        <label for="clarity">clarity</label>
35 +
        <input type="range" id="clarity" name="clarity" min="1" max="5" value="{{if $w}}{{$w.Clarity}}{{else}}3{{end}}">
36 +
        <span class="score-value" data-for="clarity">{{if $w}}{{$w.Clarity}}{{else}}3{{end}}</span>
37 +
      </div>
38 +
      <div class="score-row">
39 +
        <label for="color_intensity">intensity</label>
40 +
        <input type="range" id="color_intensity" name="color_intensity" min="1" max="5" value="{{if $w}}{{$w.ColorIntensity}}{{else}}3{{end}}">
41 +
        <span class="score-value" data-for="color_intensity">{{if $w}}{{$w.ColorIntensity}}{{else}}3{{end}}</span>
42 +
      </div>
43 +
44 +
      <div class="score-section-label">nose</div>
45 +
      <div class="score-row">
46 +
        <label for="aroma_intensity">aroma</label>
47 +
        <input type="range" id="aroma_intensity" name="aroma_intensity" min="1" max="5" value="{{if $w}}{{$w.AromaIntensity}}{{else}}3{{end}}">
48 +
        <span class="score-value" data-for="aroma_intensity">{{if $w}}{{$w.AromaIntensity}}{{else}}3{{end}}</span>
49 +
      </div>
50 +
      <div class="score-row">
51 +
        <label for="nose_complexity">complexity</label>
52 +
        <input type="range" id="nose_complexity" name="nose_complexity" min="1" max="5" value="{{if $w}}{{$w.NoseComplexity}}{{else}}3{{end}}">
53 +
        <span class="score-value" data-for="nose_complexity">{{if $w}}{{$w.NoseComplexity}}{{else}}3{{end}}</span>
54 +
      </div>
55 +
56 +
      <div class="score-section-label">palate</div>
57 +
      <div class="score-row">
58 +
        <label for="sweetness">sweetness</label>
59 +
        <input type="range" id="sweetness" name="sweetness" min="1" max="5" value="{{if $w}}{{$w.Sweetness}}{{else}}3{{end}}">
60 +
        <span class="score-value" data-for="sweetness">{{if $w}}{{$w.Sweetness}}{{else}}3{{end}}</span>
61 +
      </div>
62 +
      <div class="score-row">
63 +
        <label for="acidity">acidity</label>
64 +
        <input type="range" id="acidity" name="acidity" min="1" max="5" value="{{if $w}}{{$w.Acidity}}{{else}}3{{end}}">
65 +
        <span class="score-value" data-for="acidity">{{if $w}}{{$w.Acidity}}{{else}}3{{end}}</span>
66 +
      </div>
67 +
      <div class="score-row">
68 +
        <label for="tannin">tannin</label>
69 +
        <input type="range" id="tannin" name="tannin" min="1" max="5" value="{{if $w}}{{$w.Tannin}}{{else}}3{{end}}">
70 +
        <span class="score-value" data-for="tannin">{{if $w}}{{$w.Tannin}}{{else}}3{{end}}</span>
71 +
      </div>
72 +
      <div class="score-row">
73 +
        <label for="alcohol">alcohol</label>
74 +
        <input type="range" id="alcohol" name="alcohol" min="1" max="5" value="{{if $w}}{{$w.Alcohol}}{{else}}3{{end}}">
75 +
        <span class="score-value" data-for="alcohol">{{if $w}}{{$w.Alcohol}}{{else}}3{{end}}</span>
76 +
      </div>
77 +
      <div class="score-row">
78 +
        <label for="body">body</label>
79 +
        <input type="range" id="body" name="body" min="1" max="5" value="{{if $w}}{{$w.Body}}{{else}}3{{end}}">
80 +
        <span class="score-value" data-for="body">{{if $w}}{{$w.Body}}{{else}}3{{end}}</span>
81 +
      </div>
82 +
    </div>
83 +
84 +
    <button type="submit">{{if $w}}update{{else}}create{{end}}</button>
85 +
  </form>
86 +
87 +
  <script>
88 +
    document.querySelectorAll('input[type="range"]').forEach(function(input) {
89 +
      input.addEventListener('input', function() {
90 +
        var span = document.querySelector('.score-value[data-for="' + this.id + '"]');
91 +
        if (span) span.textContent = this.value;
92 +
      });
93 +
    });
94 +
95 +
    {{if .HasAnthropicKey}}
96 +
    async function analyzeImage() {
97 +
      var fileInput = document.getElementById('image');
98 +
      if (!fileInput.files.length) return;
99 +
      var formData = new FormData();
100 +
      formData.append('image', fileInput.files[0]);
101 +
      var btn = document.getElementById('analyze-btn');
102 +
      btn.textContent = 'analyzing...';
103 +
      btn.disabled = true;
104 +
      try {
105 +
        var res = await fetch('/admin/analyze-image', { method: 'POST', body: formData });
106 +
        if (res.ok) {
107 +
          var data = await res.json();
108 +
          if (data.name) document.getElementById('name').value = data.name;
109 +
          if (data.origin) document.getElementById('origin').value = data.origin;
110 +
          if (data.grape) document.getElementById('grape').value = data.grape;
111 +
          if (data.background) document.getElementById('background').value = data.background;
112 +
        }
113 +
      } catch (e) { console.error('Analysis failed:', e); }
114 +
      finally { btn.textContent = 'analyze'; btn.disabled = false; }
115 +
    }
116 +
    {{end}}
117 +
  </script>
118 +
{{end}}
apps/cellar-go/templates/wishlist.html (added) +33 −0
1 +
{{define "wishlist.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Wishlist - Cellar{{end}}
3 +
{{define "nav"}}
4 +
<nav class="links">
5 +
  <a href="/admin/wishlist/new">new</a>
6 +
  <a href="/">cellar</a>
7 +
</nav>
8 +
{{end}}
9 +
{{define "content"}}
10 +
  {{if not .Wines}}<p class="empty">wishlist empty</p>{{end}}
11 +
  <div class="admin-list">
12 +
    {{$isAdmin := .IsAdmin}}
13 +
    {{range .Wines}}
14 +
    <div class="admin-item">
15 +
      <div class="admin-item-info">
16 +
        <a href="/wines/{{.ShortID}}" class="admin-item-name">{{.Name}}</a>
17 +
        <span class="admin-item-meta">{{.Origin}}{{if .Grape}} &middot; {{.Grape}}{{end}}</span>
18 +
      </div>
19 +
      {{if $isAdmin}}
20 +
      <div class="admin-actions">
21 +
        <a href="/admin/wishlist/edit/{{.ShortID}}">edit</a>
22 +
        <form method="POST" action="/admin/wishlist/promote/{{.ShortID}}" class="inline-form">
23 +
          <button type="submit" class="link-button">promote</button>
24 +
        </form>
25 +
        <form method="POST" action="/admin/wishlist/delete/{{.ShortID}}" class="inline-form" onsubmit="return confirm('delete this wine?')">
26 +
          <button type="submit" class="link-button">delete</button>
27 +
        </form>
28 +
      </div>
29 +
      {{end}}
30 +
    </div>
31 +
    {{end}}
32 +
  </div>
33 +
{{end}}
apps/cellar-go/templates/wishlist_form.html (added) +58 −0
1 +
{{define "wishlist_form.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{if .Wine}}Edit{{else}}New{{end}} Wishlist Wine - Cellar{{end}}
3 +
{{define "content"}}
4 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5 +
  {{$w := .Wine}}
6 +
  <form method="POST" enctype="multipart/form-data"
7 +
    action="{{if $w}}/admin/wishlist/edit/{{$w.ShortID}}{{else}}/admin/wishlist/new{{end}}"
8 +
    class="form">
9 +
10 +
    <label for="image">image</label>
11 +
    <div class="image-upload-row">
12 +
      <input type="file" id="image" name="image" accept="image/*">
13 +
      {{if .HasAnthropicKey}}<button type="button" id="analyze-btn" onclick="analyzeImage()">analyze</button>{{end}}
14 +
    </div>
15 +
16 +
    <label for="name">name</label>
17 +
    <input type="text" id="name" name="name" required value="{{if $w}}{{$w.Name}}{{end}}">
18 +
19 +
    <label for="origin">origin</label>
20 +
    <input type="text" id="origin" name="origin" value="{{if $w}}{{$w.Origin}}{{end}}">
21 +
22 +
    <label for="grape">grape</label>
23 +
    <input type="text" id="grape" name="grape" value="{{if $w}}{{$w.Grape}}{{end}}">
24 +
25 +
    <label for="notes">notes</label>
26 +
    <textarea id="notes" name="notes" rows="5">{{if $w}}{{$w.Notes}}{{end}}</textarea>
27 +
28 +
    <label for="background">background</label>
29 +
    <textarea id="background" name="background" rows="5">{{if $w}}{{$w.Background}}{{end}}</textarea>
30 +
31 +
    <button type="submit">{{if $w}}update{{else}}create{{end}}</button>
32 +
  </form>
33 +
34 +
  <script>
35 +
    {{if .HasAnthropicKey}}
36 +
    async function analyzeImage() {
37 +
      var fileInput = document.getElementById('image');
38 +
      if (!fileInput.files.length) return;
39 +
      var formData = new FormData();
40 +
      formData.append('image', fileInput.files[0]);
41 +
      var btn = document.getElementById('analyze-btn');
42 +
      btn.textContent = 'analyzing...';
43 +
      btn.disabled = true;
44 +
      try {
45 +
        var res = await fetch('/admin/analyze-image', { method: 'POST', body: formData });
46 +
        if (res.ok) {
47 +
          var data = await res.json();
48 +
          if (data.name) document.getElementById('name').value = data.name;
49 +
          if (data.origin) document.getElementById('origin').value = data.origin;
50 +
          if (data.grape) document.getElementById('grape').value = data.grape;
51 +
          if (data.background) document.getElementById('background').value = data.background;
52 +
        }
53 +
      } catch (e) { console.error('Analysis failed:', e); }
54 +
      finally { btn.textContent = 'analyze'; btn.disabled = false; }
55 +
    }
56 +
    {{end}}
57 +
  </script>
58 +
{{end}}
apps/easel-go/.env.example (added) +9 −0
1 +
HOST=127.0.0.1
2 +
PORT=4242
3 +
EASEL_DB_PATH=easel.sqlite
4 +
EASEL_TIMEZONE=UTC
5 +
EASEL_CLASSIFICATIONS=painting
6 +
EASEL_EXCLUDE_TERMS=erotic,erotica,shunga
7 +
EASEL_BACKFILL_DAYS=0
8 +
EASEL_MAX_DEDUP_RETRIES=10
9 +
EASEL_BASE_URL=http://localhost:4242
apps/easel-go/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t easel-go -f apps/easel-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/easel-go/go.mod apps/easel-go/go.sum ./apps/easel-go/
6 +
WORKDIR /app/apps/easel-go
7 +
RUN go mod download
8 +
COPY apps/easel-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /easel-go .
10 +
11 +
FROM debian:bookworm-slim
12 +
RUN apt-get update && apt-get install -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
13 +
COPY --from=builder /easel-go /usr/local/bin/easel-go
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=4242
17 +
EXPOSE 4242
18 +
CMD ["easel-go"]
apps/easel-go/README.md (added) +17 −0
1 +
# easel-go
2 +
3 +
Go rewrite of [easel](../easel). A daily painting from the Art Institute of
4 +
Chicago, persisted to SQLite. Past days browsable; future days unavailable.
5 +
6 +
## Routes
7 +
8 +
- `GET /` — today's artwork
9 +
- `GET /day/{YYYY-MM-DD}` — specific past day
10 +
- `GET /archive` — full archive
11 +
- `GET /api/today` / `GET /api/day/{date}` / `GET /api/archive` — JSON
12 +
- `GET /feed.xml` — Atom feed
13 +
14 +
## Env
15 +
16 +
See `.env.example`. Notes: timezone uses Go's `time.LoadLocation`, which needs
17 +
the system tzdata (Debian slim base in the Dockerfile pulls `tzdata`).
apps/easel-go/aic.go (added) +232 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"context"
6 +
	"database/sql"
7 +
	"encoding/json"
8 +
	"fmt"
9 +
	"math/rand/v2"
10 +
	"net/http"
11 +
	"net/url"
12 +
	"time"
13 +
)
14 +
15 +
const aicSearchURL = "https://api.artic.edu/api/v1/artworks/search"
16 +
17 +
var aicFields = []string{
18 +
	"id", "title", "artist_display", "artist_title", "date_display",
19 +
	"medium_display", "dimensions", "place_of_origin", "credit_line",
20 +
	"description", "short_description", "image_id",
21 +
}
22 +
23 +
var aicExcludeFields = []string{
24 +
	"title", "description", "short_description", "term_titles", "subject_titles",
25 +
	"category_titles", "classification_titles",
26 +
}
27 +
28 +
type rawArtwork struct {
29 +
	ID               int64   `json:"id"`
30 +
	Title            *string `json:"title"`
31 +
	ArtistDisplay    *string `json:"artist_display"`
32 +
	ArtistTitle      *string `json:"artist_title"`
33 +
	DateDisplay      *string `json:"date_display"`
34 +
	MediumDisplay    *string `json:"medium_display"`
35 +
	Dimensions       *string `json:"dimensions"`
36 +
	PlaceOfOrigin    *string `json:"place_of_origin"`
37 +
	CreditLine       *string `json:"credit_line"`
38 +
	Description      *string `json:"description"`
39 +
	ShortDescription *string `json:"short_description"`
40 +
	ImageID          *string `json:"image_id"`
41 +
}
42 +
43 +
type searchResponse struct {
44 +
	Pagination struct {
45 +
		Total uint64 `json:"total"`
46 +
	} `json:"pagination"`
47 +
	Data []rawArtwork `json:"data"`
48 +
}
49 +
50 +
func buildHTTPClient() *http.Client {
51 +
	return &http.Client{Timeout: 20 * time.Second}
52 +
}
53 +
54 +
func buildAICParams(classifications, excludeTerms []string) string {
55 +
	terms := make([]string, 0, len(classifications))
56 +
	for _, c := range classifications {
57 +
		terms = append(terms, lower(c))
58 +
	}
59 +
	mustNot := make([]map[string]any, 0, len(excludeTerms))
60 +
	for _, t := range excludeTerms {
61 +
		mustNot = append(mustNot, map[string]any{
62 +
			"multi_match": map[string]any{
63 +
				"query":  t,
64 +
				"fields": aicExcludeFields,
65 +
				"type":   "phrase",
66 +
			},
67 +
		})
68 +
	}
69 +
	body := map[string]any{
70 +
		"query": map[string]any{
71 +
			"bool": map[string]any{
72 +
				"must": []any{
73 +
					map[string]any{"term": map[string]any{"is_public_domain": true}},
74 +
					map[string]any{"terms": map[string]any{"classification_title.keyword": terms}},
75 +
					map[string]any{"exists": map[string]any{"field": "image_id"}},
76 +
				},
77 +
				"must_not": mustNot,
78 +
			},
79 +
		},
80 +
	}
81 +
	buf, _ := json.Marshal(body)
82 +
	return string(buf)
83 +
}
84 +
85 +
func lower(s string) string {
86 +
	b := []byte(s)
87 +
	for i, c := range b {
88 +
		if c >= 'A' && c <= 'Z' {
89 +
			b[i] = c + 32
90 +
		}
91 +
	}
92 +
	return string(b)
93 +
}
94 +
95 +
func aicTotalMatching(ctx context.Context, client *http.Client, classifications, excludeTerms []string) (uint64, error) {
96 +
	params := buildAICParams(classifications, excludeTerms)
97 +
	u := fmt.Sprintf("%s?params=%s&limit=1&fields=id", aicSearchURL, url.QueryEscape(params))
98 +
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
99 +
	if err != nil {
100 +
		return 0, err
101 +
	}
102 +
	req.Header.Set("User-Agent", "andromeda-easel-go/0.1 (+https://github.com/stevedylandev/andromeda)")
103 +
	resp, err := client.Do(req)
104 +
	if err != nil {
105 +
		return 0, fmt.Errorf("count fetch failed: %w", err)
106 +
	}
107 +
	defer resp.Body.Close()
108 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
109 +
		return 0, fmt.Errorf("count status %s", resp.Status)
110 +
	}
111 +
	var sr searchResponse
112 +
	if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
113 +
		return 0, err
114 +
	}
115 +
	return sr.Pagination.Total, nil
116 +
}
117 +
118 +
func aicFetchArtworkAt(ctx context.Context, client *http.Client, classifications, excludeTerms []string, page uint64) (*rawArtwork, error) {
119 +
	params := buildAICParams(classifications, excludeTerms)
120 +
	u := fmt.Sprintf("%s?params=%s&limit=1&page=%d&fields=%s",
121 +
		aicSearchURL, url.QueryEscape(params), page, joinFields(aicFields))
122 +
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
123 +
	if err != nil {
124 +
		return nil, err
125 +
	}
126 +
	req.Header.Set("User-Agent", "andromeda-easel-go/0.1")
127 +
	resp, err := client.Do(req)
128 +
	if err != nil {
129 +
		return nil, fmt.Errorf("artwork fetch failed: %w", err)
130 +
	}
131 +
	defer resp.Body.Close()
132 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
133 +
		return nil, fmt.Errorf("artwork status %s", resp.Status)
134 +
	}
135 +
	body := &bytes.Buffer{}
136 +
	if _, err := body.ReadFrom(resp.Body); err != nil {
137 +
		return nil, err
138 +
	}
139 +
	var sr searchResponse
140 +
	if err := json.Unmarshal(body.Bytes(), &sr); err != nil {
141 +
		return nil, err
142 +
	}
143 +
	if len(sr.Data) == 0 {
144 +
		return nil, nil
145 +
	}
146 +
	return &sr.Data[len(sr.Data)-1], nil
147 +
}
148 +
149 +
func joinFields(fs []string) string {
150 +
	out := ""
151 +
	for i, f := range fs {
152 +
		if i > 0 {
153 +
			out += ","
154 +
		}
155 +
		out += f
156 +
	}
157 +
	return out
158 +
}
159 +
160 +
func pickUnique(ctx context.Context, client *http.Client, db *sql.DB, classifications, excludeTerms []string, maxRetries int) (*rawArtwork, error) {
161 +
	total, err := aicTotalMatching(ctx, client, classifications, excludeTerms)
162 +
	if err != nil {
163 +
		return nil, err
164 +
	}
165 +
	if total == 0 {
166 +
		return nil, fmt.Errorf("AIC search returned zero matches")
167 +
	}
168 +
	for attempt := 0; attempt <= maxRetries; attempt++ {
169 +
		page := rand.Uint64N(total) + 1
170 +
		art, err := aicFetchArtworkAt(ctx, client, classifications, excludeTerms, page)
171 +
		if err != nil {
172 +
			return nil, err
173 +
		}
174 +
		if art == nil || art.ImageID == nil || *art.ImageID == "" {
175 +
			continue
176 +
		}
177 +
		exists, err := artworkIDExists(db, art.ID)
178 +
		if err != nil {
179 +
			return nil, fmt.Errorf("dedup check: %w", err)
180 +
		}
181 +
		if exists {
182 +
			continue
183 +
		}
184 +
		return art, nil
185 +
	}
186 +
	return nil, fmt.Errorf("failed to pick non-duplicate artwork after %d retries", maxRetries+1)
187 +
}
188 +
189 +
func rawToDaily(r *rawArtwork, date, fetchedAt string) *DailyArtwork {
190 +
	if r.ImageID == nil || *r.ImageID == "" {
191 +
		return nil
192 +
	}
193 +
	title := "Untitled"
194 +
	if r.Title != nil && *r.Title != "" {
195 +
		title = *r.Title
196 +
	}
197 +
	d := &DailyArtwork{
198 +
		Date:      date,
199 +
		ArtworkID: r.ID,
200 +
		Title:     title,
201 +
		ImageID:   *r.ImageID,
202 +
		FetchedAt: fetchedAt,
203 +
	}
204 +
	if r.ArtistDisplay != nil {
205 +
		d.ArtistDisplay = sql.NullString{String: *r.ArtistDisplay, Valid: true}
206 +
	}
207 +
	if r.ArtistTitle != nil {
208 +
		d.ArtistTitle = sql.NullString{String: *r.ArtistTitle, Valid: true}
209 +
	}
210 +
	if r.DateDisplay != nil {
211 +
		d.DateDisplay = sql.NullString{String: *r.DateDisplay, Valid: true}
212 +
	}
213 +
	if r.MediumDisplay != nil {
214 +
		d.MediumDisplay = sql.NullString{String: *r.MediumDisplay, Valid: true}
215 +
	}
216 +
	if r.Dimensions != nil {
217 +
		d.Dimensions = sql.NullString{String: *r.Dimensions, Valid: true}
218 +
	}
219 +
	if r.PlaceOfOrigin != nil {
220 +
		d.PlaceOfOrigin = sql.NullString{String: *r.PlaceOfOrigin, Valid: true}
221 +
	}
222 +
	if r.CreditLine != nil {
223 +
		d.CreditLine = sql.NullString{String: *r.CreditLine, Valid: true}
224 +
	}
225 +
	if r.Description != nil {
226 +
		d.Description = sql.NullString{String: *r.Description, Valid: true}
227 +
	}
228 +
	if r.ShortDescription != nil {
229 +
		d.ShortDescription = sql.NullString{String: *r.ShortDescription, Valid: true}
230 +
	}
231 +
	return d
232 +
}
apps/easel-go/app.go (added) +68 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
	"net/http"
9 +
	"time"
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 +
	HTTP              *http.Client
20 +
	TZ                *time.Location
21 +
	TZName            string
22 +
	Classifications   []string
23 +
	ExcludeTerms      []string
24 +
	BackfillDays      int
25 +
	MaxDedupRetries   int
26 +
	BaseURL           string
27 +
}
28 +
29 +
type artworkView struct {
30 +
	Date             string
31 +
	Title            string
32 +
	ArtistDisplay    string
33 +
	DateDisplay      string
34 +
	MediumDisplay    string
35 +
	Dimensions       string
36 +
	PlaceOfOrigin    string
37 +
	CreditLine       string
38 +
	Description      string
39 +
	DescriptionHTML  template.HTML
40 +
	ShortDescription string
41 +
	ImageURL         string
42 +
	SourceURL        string
43 +
}
44 +
45 +
type archiveRow struct {
46 +
	Date   string
47 +
	Title  string
48 +
	Artist string
49 +
}
50 +
51 +
type indexPageData struct {
52 +
	TodayDate string
53 +
	Artwork   *artworkView
54 +
}
55 +
56 +
type dayPageData struct {
57 +
	Date    string
58 +
	Artwork artworkView
59 +
}
60 +
61 +
type archivePageData struct {
62 +
	Archive []archiveRow
63 +
}
64 +
65 +
type errorPageData struct {
66 +
	Title   string
67 +
	Message string
68 +
}
apps/easel-go/db.go (added) +133 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	_ "modernc.org/sqlite"
8 +
)
9 +
10 +
const easelSchema = `
11 +
CREATE TABLE IF NOT EXISTS daily_artworks (
12 +
    date              TEXT PRIMARY KEY,
13 +
    artwork_id        INTEGER NOT NULL,
14 +
    title             TEXT NOT NULL,
15 +
    artist_display    TEXT,
16 +
    artist_title      TEXT,
17 +
    date_display      TEXT,
18 +
    medium_display    TEXT,
19 +
    dimensions        TEXT,
20 +
    place_of_origin   TEXT,
21 +
    credit_line       TEXT,
22 +
    description       TEXT,
23 +
    short_description TEXT,
24 +
    image_id          TEXT NOT NULL,
25 +
    fetched_at        TEXT NOT NULL DEFAULT (datetime('now'))
26 +
);
27 +
CREATE INDEX IF NOT EXISTS idx_daily_artworks_artwork_id ON daily_artworks(artwork_id);
28 +
`
29 +
30 +
type DailyArtwork struct {
31 +
	Date             string
32 +
	ArtworkID        int64
33 +
	Title            string
34 +
	ArtistDisplay    sql.NullString
35 +
	ArtistTitle      sql.NullString
36 +
	DateDisplay      sql.NullString
37 +
	MediumDisplay    sql.NullString
38 +
	Dimensions       sql.NullString
39 +
	PlaceOfOrigin    sql.NullString
40 +
	CreditLine       sql.NullString
41 +
	Description      sql.NullString
42 +
	ShortDescription sql.NullString
43 +
	ImageID          string
44 +
	FetchedAt        string
45 +
}
46 +
47 +
const dailyCols = `date, artwork_id, title, artist_display, artist_title, date_display, medium_display, dimensions, place_of_origin, credit_line, description, short_description, image_id, fetched_at`
48 +
49 +
func openDB(path string) (*sql.DB, error) {
50 +
	db, err := sql.Open("sqlite", path)
51 +
	if err != nil {
52 +
		return nil, err
53 +
	}
54 +
	db.SetMaxOpenConns(1)
55 +
	db.SetMaxIdleConns(1)
56 +
	if _, err := db.Exec(easelSchema); err != nil {
57 +
		return nil, err
58 +
	}
59 +
	return db, nil
60 +
}
61 +
62 +
func scanDaily(s interface{ Scan(...any) error }) (*DailyArtwork, error) {
63 +
	var d DailyArtwork
64 +
	err := s.Scan(&d.Date, &d.ArtworkID, &d.Title, &d.ArtistDisplay, &d.ArtistTitle,
65 +
		&d.DateDisplay, &d.MediumDisplay, &d.Dimensions, &d.PlaceOfOrigin, &d.CreditLine,
66 +
		&d.Description, &d.ShortDescription, &d.ImageID, &d.FetchedAt)
67 +
	if errors.Is(err, sql.ErrNoRows) {
68 +
		return nil, nil
69 +
	}
70 +
	if err != nil {
71 +
		return nil, err
72 +
	}
73 +
	return &d, nil
74 +
}
75 +
76 +
func insertDaily(db *sql.DB, a *DailyArtwork) (bool, error) {
77 +
	res, err := db.Exec(
78 +
		`INSERT OR IGNORE INTO daily_artworks (`+dailyCols+`)
79 +
		 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
80 +
		a.Date, a.ArtworkID, a.Title, a.ArtistDisplay, a.ArtistTitle,
81 +
		a.DateDisplay, a.MediumDisplay, a.Dimensions, a.PlaceOfOrigin, a.CreditLine,
82 +
		a.Description, a.ShortDescription, a.ImageID, a.FetchedAt,
83 +
	)
84 +
	if err != nil {
85 +
		return false, err
86 +
	}
87 +
	n, _ := res.RowsAffected()
88 +
	return n > 0, nil
89 +
}
90 +
91 +
func getDaily(db *sql.DB, date string) (*DailyArtwork, error) {
92 +
	return scanDaily(db.QueryRow(`SELECT `+dailyCols+` FROM daily_artworks WHERE date = ?`, date))
93 +
}
94 +
95 +
func listDaily(db *sql.DB, limit int) ([]DailyArtwork, error) {
96 +
	rows, err := db.Query(`SELECT `+dailyCols+` FROM daily_artworks ORDER BY date DESC LIMIT ?`, limit)
97 +
	if err != nil {
98 +
		return nil, err
99 +
	}
100 +
	defer rows.Close()
101 +
	var out []DailyArtwork
102 +
	for rows.Next() {
103 +
		d, err := scanDaily(rows)
104 +
		if err != nil {
105 +
			return nil, err
106 +
		}
107 +
		out = append(out, *d)
108 +
	}
109 +
	return out, rows.Err()
110 +
}
111 +
112 +
func artworkIDExists(db *sql.DB, id int64) (bool, error) {
113 +
	var n int64
114 +
	err := db.QueryRow(`SELECT COUNT(*) FROM daily_artworks WHERE artwork_id = ?`, id).Scan(&n)
115 +
	if err != nil {
116 +
		return false, err
117 +
	}
118 +
	return n > 0, nil
119 +
}
120 +
121 +
func missingDates(db *sql.DB, dates []string) ([]string, error) {
122 +
	out := []string{}
123 +
	for _, d := range dates {
124 +
		var n int64
125 +
		if err := db.QueryRow(`SELECT COUNT(*) FROM daily_artworks WHERE date = ?`, d).Scan(&n); err != nil {
126 +
			return nil, err
127 +
		}
128 +
		if n == 0 {
129 +
			out = append(out, d)
130 +
		}
131 +
	}
132 +
	return out, nil
133 +
}
apps/easel-go/docker-compose.yml (added) +23 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/easel-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-4242}:${PORT:-4242}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-4242}
11 +
      - EASEL_DB_PATH=/data/easel-go.sqlite
12 +
      - EASEL_TIMEZONE=${EASEL_TIMEZONE:-UTC}
13 +
      - EASEL_CLASSIFICATIONS=${EASEL_CLASSIFICATIONS:-painting}
14 +
      - EASEL_EXCLUDE_TERMS=${EASEL_EXCLUDE_TERMS:-erotic,erotica,shunga}
15 +
      - EASEL_BACKFILL_DAYS=${EASEL_BACKFILL_DAYS:-0}
16 +
      - EASEL_MAX_DEDUP_RETRIES=${EASEL_MAX_DEDUP_RETRIES:-10}
17 +
      - EASEL_BASE_URL=${EASEL_BASE_URL:-http://localhost:4242}
18 +
    volumes:
19 +
      - easel-go-data:/data
20 +
    restart: unless-stopped
21 +
22 +
volumes:
23 +
  easel-go-data:
apps/easel-go/feed.go (added) +87 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strings"
6 +
	"time"
7 +
)
8 +
9 +
func entryPublished(date string) string {
10 +
	if d, err := time.Parse("2006-01-02", date); err == nil {
11 +
		return time.Date(d.Year(), d.Month(), d.Day(), 12, 0, 0, 0, time.UTC).Format(time.RFC3339)
12 +
	}
13 +
	return time.Now().UTC().Format(time.RFC3339)
14 +
}
15 +
16 +
func escapeXML(s string) string {
17 +
	r := strings.NewReplacer(
18 +
		"&", "&amp;",
19 +
		"<", "&lt;",
20 +
		">", "&gt;",
21 +
		`"`, "&quot;",
22 +
		"'", "&apos;",
23 +
	)
24 +
	return r.Replace(s)
25 +
}
26 +
27 +
func (a *App) atomFeedHandler(w http.ResponseWriter, r *http.Request) {
28 +
	items, err := listDaily(a.DB, 100)
29 +
	if err != nil {
30 +
		a.Log.Error("atom feed query failed", "err", err)
31 +
		w.WriteHeader(http.StatusInternalServerError)
32 +
		return
33 +
	}
34 +
	updated := time.Now().UTC().Format(time.RFC3339)
35 +
	if len(items) > 0 {
36 +
		updated = entryPublished(items[0].Date)
37 +
	}
38 +
	base := strings.TrimRight(a.BaseURL, "/")
39 +
	selfURL := base + "/feed.xml"
40 +
41 +
	var b strings.Builder
42 +
	b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
43 +
	b.WriteString("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n")
44 +
	b.WriteString("  <title>Easel — Daily Artwork</title>\n")
45 +
	b.WriteString("  <subtitle>A daily painting from the Art Institute of Chicago</subtitle>\n")
46 +
	b.WriteString(`  <link href="` + escapeXML(selfURL) + `" rel="self" type="application/atom+xml" />` + "\n")
47 +
	b.WriteString(`  <link href="` + escapeXML(base) + `" />` + "\n")
48 +
	b.WriteString("  <id>" + escapeXML(selfURL) + "</id>\n")
49 +
	b.WriteString("  <updated>" + updated + "</updated>\n")
50 +
51 +
	for _, item := range items {
52 +
		published := entryPublished(item.Date)
53 +
		entryURL := base + "/day/" + item.Date
54 +
		author := item.ArtistTitle.String
55 +
		if author == "" {
56 +
			author = item.ArtistDisplay.String
57 +
		}
58 +
		if author == "" {
59 +
			author = "Unknown"
60 +
		}
61 +
		summary := item.ShortDescription.String
62 +
		if summary == "" {
63 +
			summary = item.Description.String
64 +
		}
65 +
		image := iiifURL(item.ImageID)
66 +
		content := `<p><img src="` + escapeXML(image) + `" alt="` + escapeXML(item.Title) + `" /></p><p>` + escapeXML(summary) + `</p>`
67 +
68 +
		b.WriteString("  <entry>\n")
69 +
		b.WriteString("    <title>" + escapeXML(item.Date) + " — " + escapeXML(item.Title) + "</title>\n")
70 +
		b.WriteString(`    <link href="` + escapeXML(entryURL) + `" />` + "\n")
71 +
		b.WriteString("    <id>" + escapeXML(entryURL) + "</id>\n")
72 +
		b.WriteString("    <updated>" + published + "</updated>\n")
73 +
		b.WriteString("    <published>" + published + "</published>\n")
74 +
		b.WriteString("    <author>\n")
75 +
		b.WriteString("      <name>" + escapeXML(author) + "</name>\n")
76 +
		b.WriteString("    </author>\n")
77 +
		if summary != "" {
78 +
			b.WriteString("    <summary>" + escapeXML(summary) + "</summary>\n")
79 +
		}
80 +
		b.WriteString(`    <content type="html">` + escapeXML(content) + `</content>` + "\n")
81 +
		b.WriteString("  </entry>\n")
82 +
	}
83 +
	b.WriteString("</feed>\n")
84 +
85 +
	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
86 +
	_, _ = w.Write([]byte(b.String()))
87 +
}
apps/easel-go/go.mod (added) +29 −0
1 +
module github.com/stevedylandev/andromeda/apps/easel-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
9 +
	modernc.org/sqlite v1.37.1
10 +
)
11 +
12 +
require (
13 +
	github.com/dustin/go-humanize v1.0.1 // indirect
14 +
	github.com/google/uuid v1.6.0 // indirect
15 +
	github.com/mattn/go-isatty v0.0.20 // indirect
16 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
17 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
18 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
19 +
	golang.org/x/sys v0.33.0 // indirect
20 +
	modernc.org/libc v1.65.7 // indirect
21 +
	modernc.org/mathutil v1.7.1 // indirect
22 +
	modernc.org/memory v1.11.0 // indirect
23 +
)
24 +
25 +
replace (
26 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
27 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
28 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
29 +
)
apps/easel-go/go.sum (added) +47 −0
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/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
14 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
15 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
16 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
17 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
18 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
19 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
21 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
22 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
23 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
24 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
25 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
26 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
27 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
28 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
29 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
30 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
31 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
32 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
33 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
34 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
35 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
36 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
37 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
38 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
39 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
40 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
41 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
42 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
43 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
44 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
45 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
46 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
47 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/easel-go/handlers.go (added) +114 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"net/http"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
func iiifURL(imageID string) string {
11 +
	return "https://www.artic.edu/iiif/2/" + imageID + "/full/843,/0/default.jpg"
12 +
}
13 +
14 +
func sourceURL(id int64) string {
15 +
	return "https://www.artic.edu/artworks/" + itoa64(id)
16 +
}
17 +
18 +
func itoa64(v int64) string {
19 +
	if v == 0 {
20 +
		return "0"
21 +
	}
22 +
	negative := v < 0
23 +
	if negative {
24 +
		v = -v
25 +
	}
26 +
	buf := [20]byte{}
27 +
	i := len(buf)
28 +
	for v > 0 {
29 +
		i--
30 +
		buf[i] = byte('0' + v%10)
31 +
		v /= 10
32 +
	}
33 +
	if negative {
34 +
		i--
35 +
		buf[i] = '-'
36 +
	}
37 +
	return string(buf[i:])
38 +
}
39 +
40 +
func toArtworkView(a DailyArtwork) artworkView {
41 +
	v := artworkView{
42 +
		Date:             a.Date,
43 +
		Title:            a.Title,
44 +
		ArtistDisplay:    a.ArtistDisplay.String,
45 +
		DateDisplay:      a.DateDisplay.String,
46 +
		MediumDisplay:    a.MediumDisplay.String,
47 +
		Dimensions:       a.Dimensions.String,
48 +
		PlaceOfOrigin:    a.PlaceOfOrigin.String,
49 +
		CreditLine:       a.CreditLine.String,
50 +
		Description:      a.Description.String,
51 +
		ShortDescription: a.ShortDescription.String,
52 +
		ImageURL:         iiifURL(a.ImageID),
53 +
		SourceURL:        sourceURL(a.ArtworkID),
54 +
	}
55 +
	v.DescriptionHTML = template.HTML(v.Description)
56 +
	return v
57 +
}
58 +
59 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
60 +
	today := a.todayInTZ()
61 +
	d, err := getDaily(a.DB, today)
62 +
	if err != nil {
63 +
		a.Log.Error("index db error", "err", err)
64 +
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Error", Message: "Could not load today's artwork."}, a.Log)
65 +
		return
66 +
	}
67 +
	data := indexPageData{TodayDate: today}
68 +
	if d != nil {
69 +
		v := toArtworkView(*d)
70 +
		data.Artwork = &v
71 +
	}
72 +
	web.Render(a.Templates, w, "index.html", data, a.Log)
73 +
}
74 +
75 +
func (a *App) dayHandler(w http.ResponseWriter, r *http.Request) {
76 +
	date := r.PathValue("date")
77 +
	if _, ok := parseDate(date); !ok {
78 +
		w.WriteHeader(http.StatusBadRequest)
79 +
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Invalid date", Message: "'" + date + "' is not a valid YYYY-MM-DD date."}, a.Log)
80 +
		return
81 +
	}
82 +
	today := a.todayInTZ()
83 +
	if date > today {
84 +
		w.WriteHeader(http.StatusNotFound)
85 +
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Not yet", Message: date + " is in the future."}, a.Log)
86 +
		return
87 +
	}
88 +
	d, err := getDaily(a.DB, date)
89 +
	if err != nil {
90 +
		a.Log.Error("day db error", "err", err)
91 +
		w.WriteHeader(http.StatusInternalServerError)
92 +
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Error", Message: "Database error."}, a.Log)
93 +
		return
94 +
	}
95 +
	if d == nil {
96 +
		w.WriteHeader(http.StatusNotFound)
97 +
		web.Render(a.Templates, w, "error.html", errorPageData{Title: "Not found", Message: "No artwork stored for " + date + "."}, a.Log)
98 +
		return
99 +
	}
100 +
	web.Render(a.Templates, w, "day.html", dayPageData{Date: date, Artwork: toArtworkView(*d)}, a.Log)
101 +
}
102 +
103 +
func (a *App) archiveHandler(w http.ResponseWriter, r *http.Request) {
104 +
	items, _ := listDaily(a.DB, 1000)
105 +
	rows := make([]archiveRow, 0, len(items))
106 +
	for _, it := range items {
107 +
		artist := it.ArtistTitle.String
108 +
		if artist == "" {
109 +
			artist = it.ArtistDisplay.String
110 +
		}
111 +
		rows = append(rows, archiveRow{Date: it.Date, Title: it.Title, Artist: artist})
112 +
	}
113 +
	web.Render(a.Templates, w, "archive.html", archivePageData{Archive: rows}, a.Log)
114 +
}
apps/easel-go/handlers_api.go (added) +114 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/web"
7 +
)
8 +
9 +
type apiArtwork struct {
10 +
	Date             string  `json:"date"`
11 +
	ArtworkID        int64   `json:"artwork_id"`
12 +
	Title            string  `json:"title"`
13 +
	ArtistDisplay    *string `json:"artist_display,omitempty"`
14 +
	DateDisplay      *string `json:"date_display,omitempty"`
15 +
	MediumDisplay    *string `json:"medium_display,omitempty"`
16 +
	Dimensions       *string `json:"dimensions,omitempty"`
17 +
	PlaceOfOrigin    *string `json:"place_of_origin,omitempty"`
18 +
	CreditLine       *string `json:"credit_line,omitempty"`
19 +
	ShortDescription *string `json:"short_description,omitempty"`
20 +
	ImageID          string  `json:"image_id"`
21 +
	ImageURL         string  `json:"image_url"`
22 +
	SourceURL        string  `json:"source_url"`
23 +
}
24 +
25 +
func toAPI(a DailyArtwork) apiArtwork {
26 +
	out := apiArtwork{
27 +
		Date:      a.Date,
28 +
		ArtworkID: a.ArtworkID,
29 +
		Title:     a.Title,
30 +
		ImageID:   a.ImageID,
31 +
		ImageURL:  iiifURL(a.ImageID),
32 +
		SourceURL: sourceURL(a.ArtworkID),
33 +
	}
34 +
	if a.ArtistDisplay.Valid {
35 +
		v := a.ArtistDisplay.String
36 +
		out.ArtistDisplay = &v
37 +
	}
38 +
	if a.DateDisplay.Valid {
39 +
		v := a.DateDisplay.String
40 +
		out.DateDisplay = &v
41 +
	}
42 +
	if a.MediumDisplay.Valid {
43 +
		v := a.MediumDisplay.String
44 +
		out.MediumDisplay = &v
45 +
	}
46 +
	if a.Dimensions.Valid {
47 +
		v := a.Dimensions.String
48 +
		out.Dimensions = &v
49 +
	}
50 +
	if a.PlaceOfOrigin.Valid {
51 +
		v := a.PlaceOfOrigin.String
52 +
		out.PlaceOfOrigin = &v
53 +
	}
54 +
	if a.CreditLine.Valid {
55 +
		v := a.CreditLine.String
56 +
		out.CreditLine = &v
57 +
	}
58 +
	if a.ShortDescription.Valid {
59 +
		v := a.ShortDescription.String
60 +
		out.ShortDescription = &v
61 +
	}
62 +
	return out
63 +
}
64 +
65 +
func (a *App) apiToday(w http.ResponseWriter, r *http.Request) {
66 +
	d, err := getDaily(a.DB, a.todayInTZ())
67 +
	if err != nil {
68 +
		a.Log.Error("api_today db error", "err", err)
69 +
		w.WriteHeader(http.StatusInternalServerError)
70 +
		return
71 +
	}
72 +
	if d == nil {
73 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "today not yet populated"})
74 +
		return
75 +
	}
76 +
	web.WriteJSON(w, http.StatusOK, toAPI(*d))
77 +
}
78 +
79 +
func (a *App) apiDay(w http.ResponseWriter, r *http.Request) {
80 +
	date := r.PathValue("date")
81 +
	if _, ok := parseDate(date); !ok {
82 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid date format"})
83 +
		return
84 +
	}
85 +
	if date > a.todayInTZ() {
86 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "future date"})
87 +
		return
88 +
	}
89 +
	d, err := getDaily(a.DB, date)
90 +
	if err != nil {
91 +
		a.Log.Error("api_day db error", "err", err)
92 +
		w.WriteHeader(http.StatusInternalServerError)
93 +
		return
94 +
	}
95 +
	if d == nil {
96 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "no record for date"})
97 +
		return
98 +
	}
99 +
	web.WriteJSON(w, http.StatusOK, toAPI(*d))
100 +
}
101 +
102 +
func (a *App) apiArchive(w http.ResponseWriter, r *http.Request) {
103 +
	items, err := listDaily(a.DB, 1000)
104 +
	if err != nil {
105 +
		a.Log.Error("api_archive db error", "err", err)
106 +
		w.WriteHeader(http.StatusInternalServerError)
107 +
		return
108 +
	}
109 +
	out := make([]apiArtwork, 0, len(items))
110 +
	for _, it := range items {
111 +
		out = append(out, toAPI(it))
112 +
	}
113 +
	web.WriteJSON(w, http.StatusOK, out)
114 +
}
apps/easel-go/main.go (added) +74 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"html/template"
6 +
	"log"
7 +
	"log/slog"
8 +
	"net/http"
9 +
	"os"
10 +
	"strings"
11 +
	"time"
12 +
13 +
	"github.com/stevedylandev/andromeda/crates-go/config"
14 +
)
15 +
16 +
func splitCommaTrim(s string) []string {
17 +
	out := []string{}
18 +
	for _, part := range strings.Split(s, ",") {
19 +
		if v := strings.TrimSpace(part); v != "" {
20 +
			out = append(out, v)
21 +
		}
22 +
	}
23 +
	return out
24 +
}
25 +
26 +
func main() {
27 +
	config.LoadDotEnv(".env")
28 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
29 +
30 +
	dbPath := config.Getenv("EASEL_DB_PATH", "easel.sqlite")
31 +
	db, err := openDB(dbPath)
32 +
	if err != nil {
33 +
		log.Fatal(err)
34 +
	}
35 +
	defer db.Close()
36 +
37 +
	tzName := config.Getenv("EASEL_TIMEZONE", "UTC")
38 +
	loc, err := time.LoadLocation(tzName)
39 +
	if err != nil {
40 +
		logger.Warn("invalid EASEL_TIMEZONE, falling back to UTC", "value", tzName)
41 +
		loc = time.UTC
42 +
		tzName = "UTC"
43 +
	}
44 +
45 +
	classifications := splitCommaTrim(config.Getenv("EASEL_CLASSIFICATIONS", "painting"))
46 +
	if len(classifications) == 0 {
47 +
		log.Fatal("EASEL_CLASSIFICATIONS resolved to empty list")
48 +
	}
49 +
	excludeTerms := splitCommaTrim(config.Getenv("EASEL_EXCLUDE_TERMS", "erotic,erotica,shunga"))
50 +
51 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
52 +
	app := &App{
53 +
		DB:              db,
54 +
		Log:             logger,
55 +
		Templates:       tmpl,
56 +
		HTTP:            buildHTTPClient(),
57 +
		TZ:              loc,
58 +
		TZName:          tzName,
59 +
		Classifications: classifications,
60 +
		ExcludeTerms:    excludeTerms,
61 +
		BackfillDays:    config.GetenvInt("EASEL_BACKFILL_DAYS", 0),
62 +
		MaxDedupRetries: config.GetenvInt("EASEL_MAX_DEDUP_RETRIES", 10),
63 +
		BaseURL:         strings.TrimRight(config.Getenv("EASEL_BASE_URL", "http://localhost:4242"), "/"),
64 +
	}
65 +
	logger.Info("easel-go starting", "tz", tzName, "classifications", classifications, "exclude_terms", excludeTerms, "backfill_days", app.BackfillDays, "retries", app.MaxDedupRetries)
66 +
67 +
	go app.runScheduler(context.Background())
68 +
69 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "4242")
70 +
	logger.Info("easel-go server running", "addr", addr)
71 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
72 +
		log.Fatal(err)
73 +
	}
74 +
}
apps/easel-go/routes.go (added) +30 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
func (a *App) routes() *http.ServeMux {
11 +
	mux := http.NewServeMux()
12 +
	mux.HandleFunc("GET /", a.indexHandler)
13 +
	mux.HandleFunc("GET /day/{date}", a.dayHandler)
14 +
	mux.HandleFunc("GET /archive", a.archiveHandler)
15 +
	mux.HandleFunc("GET /feed.xml", a.withCORS(a.atomFeedHandler))
16 +
	mux.HandleFunc("GET /api/today", a.withCORS(a.apiToday))
17 +
	mux.HandleFunc("GET /api/day/{date}", a.withCORS(a.apiDay))
18 +
	mux.HandleFunc("GET /api/archive", a.withCORS(a.apiArchive))
19 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
20 +
	darkmatter.Mount(mux, "/assets")
21 +
	return mux
22 +
}
23 +
24 +
func (a *App) withCORS(next http.HandlerFunc) http.HandlerFunc {
25 +
	return func(w http.ResponseWriter, r *http.Request) {
26 +
		w.Header().Set("Access-Control-Allow-Origin", "*")
27 +
		w.Header().Set("Access-Control-Allow-Methods", "GET")
28 +
		next(w, r)
29 +
	}
30 +
}
apps/easel-go/scheduler.go (added) +94 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"time"
6 +
)
7 +
8 +
func (a *App) todayInTZ() string {
9 +
	return time.Now().In(a.TZ).Format("2006-01-02")
10 +
}
11 +
12 +
func (a *App) pastNDates(n int) []string {
13 +
	today := time.Now().In(a.TZ).Truncate(24 * time.Hour)
14 +
	out := make([]string, 0, n)
15 +
	for i := 1; i <= n; i++ {
16 +
		out = append(out, today.AddDate(0, 0, -i).Format("2006-01-02"))
17 +
	}
18 +
	return out
19 +
}
20 +
21 +
func parseDate(s string) (time.Time, bool) {
22 +
	t, err := time.Parse("2006-01-02", s)
23 +
	return t, err == nil
24 +
}
25 +
26 +
func (a *App) ensureDay(ctx context.Context, date string) error {
27 +
	if d, err := getDaily(a.DB, date); err != nil {
28 +
		return err
29 +
	} else if d != nil {
30 +
		return nil
31 +
	}
32 +
	raw, err := pickUnique(ctx, a.HTTP, a.DB, a.Classifications, a.ExcludeTerms, a.MaxDedupRetries)
33 +
	if err != nil {
34 +
		return err
35 +
	}
36 +
	now := time.Now().UTC().Format(time.RFC3339)
37 +
	daily := rawToDaily(raw, date, now)
38 +
	if daily == nil {
39 +
		return errMissingImage
40 +
	}
41 +
	if _, err := insertDaily(a.DB, daily); err != nil {
42 +
		return err
43 +
	}
44 +
	a.Log.Info("stored artwork", "artwork_id", daily.ArtworkID, "date", date, "image_id", daily.ImageID)
45 +
	return nil
46 +
}
47 +
48 +
var errMissingImage = &simpleError{"missing image_id on selected artwork"}
49 +
50 +
type simpleError struct{ msg string }
51 +
52 +
func (e *simpleError) Error() string { return e.msg }
53 +
54 +
func (a *App) runScheduler(ctx context.Context) {
55 +
	if err := a.ensureDay(ctx, a.todayInTZ()); err != nil {
56 +
		a.Log.Warn("startup ensure_day failed", "err", err)
57 +
	}
58 +
	if a.BackfillDays > 0 {
59 +
		dates := a.pastNDates(a.BackfillDays)
60 +
		missing, err := missingDates(a.DB, dates)
61 +
		if err != nil {
62 +
			a.Log.Error("backfill missing_dates failed", "err", err)
63 +
		} else {
64 +
			a.Log.Info("backfill", "missing", len(missing), "window_days", a.BackfillDays)
65 +
			for _, d := range missing {
66 +
				if err := a.ensureDay(ctx, d); err != nil {
67 +
					a.Log.Warn("backfill day failed", "date", d, "err", err)
68 +
				}
69 +
			}
70 +
		}
71 +
	}
72 +
	for {
73 +
		dur := a.durationUntilNextMidnight()
74 +
		a.Log.Info("scheduler sleeping", "seconds", dur.Seconds(), "tz", a.TZName)
75 +
		select {
76 +
		case <-ctx.Done():
77 +
			return
78 +
		case <-time.After(dur):
79 +
		}
80 +
		if err := a.ensureDay(ctx, a.todayInTZ()); err != nil {
81 +
			a.Log.Warn("scheduled ensure_day failed", "err", err)
82 +
		}
83 +
	}
84 +
}
85 +
86 +
func (a *App) durationUntilNextMidnight() time.Duration {
87 +
	now := time.Now().In(a.TZ)
88 +
	nextDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 1, 0, a.TZ).AddDate(0, 0, 1)
89 +
	delta := nextDay.Sub(now)
90 +
	if delta < time.Second {
91 +
		return time.Hour
92 +
	}
93 +
	return delta
94 +
}
apps/easel-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/easel-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/easel-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/easel-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/easel-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/easel-go/static/styles.css (added) +78 −0
1 +
.artwork-figure {
2 +
  margin: 0 0 1rem;
3 +
  border: 1px solid #333;
4 +
}
5 +
6 +
.artwork-figure img {
7 +
  display: block;
8 +
  width: 100%;
9 +
  height: auto;
10 +
}
11 +
12 +
.artwork-meta {
13 +
  margin-bottom: 0.5rem;
14 +
}
15 +
16 +
.artwork-date {
17 +
  opacity: 0.5;
18 +
  font-size: 12px;
19 +
}
20 +
21 +
.artwork-title {
22 +
  font-size: 16px;
23 +
  font-weight: 700;
24 +
}
25 +
26 +
.artwork-artist {
27 +
  opacity: 0.7;
28 +
}
29 +
30 +
.artwork-details {
31 +
  display: grid;
32 +
  grid-template-columns: max-content 1fr;
33 +
  gap: 0.25rem 1rem;
34 +
  margin: 1rem 0;
35 +
  font-size: 13px;
36 +
}
37 +
38 +
.artwork-details dt {
39 +
  opacity: 0.5;
40 +
}
41 +
42 +
.artwork-description {
43 +
  opacity: 0.85;
44 +
  font-size: 13px;
45 +
}
46 +
47 +
.artwork-description p + p {
48 +
  margin-top: 0.75rem;
49 +
}
50 +
51 +
.archive-list {
52 +
  margin-top: 2rem;
53 +
  border-top: 1px solid #333;
54 +
  padding-top: 1rem;
55 +
  width: 100%;
56 +
}
57 +
58 +
.archive-list h3 {
59 +
  font-size: 12px;
60 +
  opacity: 0.5;
61 +
  text-transform: uppercase;
62 +
  letter-spacing: 0.05em;
63 +
  margin-bottom: 0.5rem;
64 +
}
65 +
66 +
.item a {
67 +
  display: grid;
68 +
  grid-template-columns: 90px 1fr auto;
69 +
  gap: 0.75rem;
70 +
  text-decoration: none;
71 +
  color: inherit;
72 +
}
73 +
74 +
.item-title {
75 +
  overflow: hidden;
76 +
  text-overflow: ellipsis;
77 +
  white-space: nowrap;
78 +
}
apps/easel-go/templates/archive.html (added) +20 −0
1 +
{{define "archive.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Easel — Archive{{end}}
3 +
{{define "content"}}
4 +
  <h2>Archive</h2>
5 +
  {{if not .Archive}}
6 +
    <p class="empty">No artworks stored yet.</p>
7 +
  {{else}}
8 +
    <ul class="item-list">
9 +
      {{range .Archive}}
10 +
      <li class="item">
11 +
        <a href="/day/{{.Date}}">
12 +
          <span class="item-meta">{{.Date}}</span>
13 +
          <span class="item-title"><em>{{.Title}}</em></span>
14 +
          {{if .Artist}}<span class="item-meta">{{.Artist}}</span>{{end}}
15 +
        </a>
16 +
      </li>
17 +
      {{end}}
18 +
    </ul>
19 +
  {{end}}
20 +
{{end}}
apps/easel-go/templates/base.html (added) +60 −0
1 +
{{define "base.html"}}<!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 +
    <title>{{block "title" .}}Easel{{end}}</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta property="og:title" content="Easel">
13 +
    <meta property="og:description" content="A daily painting from the Art Institute of Chicago">
14 +
    <meta property="og:image" content="/static/og.png">
15 +
    <meta property="og:type" content="website">
16 +
    <meta name="theme-color" content="#121113" />
17 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
18 +
    <link rel="stylesheet" href="/static/styles.css" />
19 +
    <meta name="description" content="A daily painting from the Art Institute of Chicago" />
20 +
    <link rel="alternate" type="application/atom+xml" title="Easel — Daily Artwork" href="/feed.xml" />
21 +
  </head>
22 +
  <body>
23 +
    <header class="header">
24 +
      <a href="/" class="logo">EASEL</a>
25 +
      <nav class="links">
26 +
        <a href="/">today</a>
27 +
        <a href="/archive">archive</a>
28 +
        <a href="/feed.xml">rss</a>
29 +
      </nav>
30 +
    </header>
31 +
    <main>
32 +
      {{block "content" .}}{{end}}
33 +
    </main>
34 +
  </body>
35 +
</html>{{end}}
36 +
37 +
{{define "artwork"}}
38 +
<article class="artwork">
39 +
  <figure class="artwork-figure">
40 +
    <img src="{{.ImageURL}}" alt="{{.Title}}" loading="lazy" />
41 +
  </figure>
42 +
  <header class="artwork-meta">
43 +
    <p class="artwork-date">{{.Date}}</p>
44 +
    <h2 class="artwork-title">
45 +
      <a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"><em>{{.Title}}</em></a>
46 +
    </h2>
47 +
    {{if .ArtistDisplay}}<p class="artwork-artist">{{.ArtistDisplay}}</p>{{end}}
48 +
  </header>
49 +
  <dl class="artwork-details">
50 +
    {{if .DateDisplay}}<dt>Date</dt><dd>{{.DateDisplay}}</dd>{{end}}
51 +
    {{if .PlaceOfOrigin}}<dt>Origin</dt><dd>{{.PlaceOfOrigin}}</dd>{{end}}
52 +
    {{if .MediumDisplay}}<dt>Medium</dt><dd>{{.MediumDisplay}}</dd>{{end}}
53 +
    {{if .Dimensions}}<dt>Dimensions</dt><dd>{{.Dimensions}}</dd>{{end}}
54 +
    {{if .CreditLine}}<dt>Credit</dt><dd>{{.CreditLine}}</dd>{{end}}
55 +
  </dl>
56 +
  {{if .Description}}<div class="artwork-description">{{.DescriptionHTML}}</div>
57 +
  {{else if .ShortDescription}}<p class="artwork-description">{{.ShortDescription}}</p>
58 +
  {{end}}
59 +
</article>
60 +
{{end}}
apps/easel-go/templates/day.html (added) +5 −0
1 +
{{define "day.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Easel — {{.Date}}{{end}}
3 +
{{define "content"}}
4 +
  {{template "artwork" .Artwork}}
5 +
{{end}}
apps/easel-go/templates/error.html (added) +9 −0
1 +
{{define "error.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Easel — {{.Title}}{{end}}
3 +
{{define "content"}}
4 +
  <div class="error-page">
5 +
    <h2>{{.Title}}</h2>
6 +
    <p>{{.Message}}</p>
7 +
    <p><a href="/">← back to today</a></p>
8 +
  </div>
9 +
{{end}}
apps/easel-go/templates/index.html (added) +11 −0
1 +
{{define "index.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Easel — {{.TodayDate}}{{end}}
3 +
{{define "content"}}
4 +
  {{if .Artwork}}
5 +
    {{template "artwork" .Artwork}}
6 +
  {{else}}
7 +
    <div class="empty">
8 +
      <p>Today's artwork ({{.TodayDate}}) is not yet available. Check back shortly.</p>
9 +
    </div>
10 +
  {{end}}
11 +
{{end}}
apps/feeds-go/app.go +4 −1
5 5
	"embed"
6 6
	"html/template"
7 7
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 10
)
9 11
10 -
//go:embed templates/*.html static/* assets/* assets/fonts/*
12 +
//go:embed templates/*.html static/*
11 13
var appFS embed.FS
12 14
13 15
type App struct {
14 16
	DB                 *sql.DB
15 17
	Log                *slog.Logger
16 18
	Templates          *template.Template
19 +
	Sessions           *auth.Store
17 20
	AdminPassword      string
18 21
	APIKey             string
19 22
	CookieSecure       bool
apps/feeds-go/assets/darkmatter.css → crates-go/darkmatter/assets/darkmatter.css +0 −0
apps/feeds-go/assets/fonts/CommitMono-400-Regular.otf → crates-go/darkmatter/assets/fonts/CommitMono-400-Regular.otf +0 −0

Binary file — no preview.

apps/feeds-go/assets/fonts/CommitMono-700-Regular.otf → crates-go/darkmatter/assets/fonts/CommitMono-700-Regular.otf +0 −0

Binary file — no preview.

apps/feeds-go/db.go +0 −29
13 13
const subscriptionSelectColumns = `id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at`
14 14
15 15
const feedsSchema = `
16 -
CREATE TABLE IF NOT EXISTS sessions (
17 -
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
18 -
    token      TEXT NOT NULL UNIQUE,
19 -
    expires_at TEXT NOT NULL
20 -
);
21 -
22 16
CREATE TABLE IF NOT EXISTS categories (
23 17
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
24 18
    name       TEXT NOT NULL UNIQUE,
380 374
func setSetting(db *sql.DB, key, value string) error {
381 375
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
382 376
	return err
383 -
}
384 -
385 -
func createSession(db *sql.DB, token string, expiresAt time.Time) error {
386 -
	_, err := db.Exec(`INSERT INTO sessions (token, expires_at) VALUES (?, ?)`, token, expiresAt.UTC().Format("2006-01-02 15:04:05"))
387 -
	return err
388 -
}
389 -
390 -
func isValidSession(db *sql.DB, token string) bool {
391 -
	var expires string
392 -
	err := db.QueryRow(`SELECT expires_at FROM sessions WHERE token = ?`, token).Scan(&expires)
393 -
	if err != nil {
394 -
		return false
395 -
	}
396 -
	t, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.UTC)
397 -
	return err == nil && t.After(time.Now().UTC())
398 -
}
399 -
400 -
func deleteSession(db *sql.DB, token string) {
401 -
	_, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
402 -
}
403 -
404 -
func pruneExpiredSessions(db *sql.DB) {
405 -
	_, _ = db.Exec(`DELETE FROM sessions WHERE expires_at < datetime('now')`)
406 377
}
407 378
408 379
func querySubscription(db *sql.DB, query string, args ...any) (*Subscription, error) {
apps/feeds-go/go.mod +13 −2
3 3
go 1.24.4
4 4
5 5
require (
6 -
	github.com/google/uuid v1.6.0
7 6
	github.com/mmcdole/gofeed v1.3.0
8 -
	golang.org/x/crypto v0.39.0
7 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
9 11
	golang.org/x/net v0.41.0
10 12
	modernc.org/sqlite v1.37.1
11 13
)
12 14
15 +
replace (
16 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
17 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
18 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
19 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
20 +
)
21 +
13 22
require (
14 23
	github.com/PuerkitoBio/goquery v1.8.0 // indirect
15 24
	github.com/andybalholm/cascadia v1.3.1 // indirect
16 25
	github.com/dustin/go-humanize v1.0.1 // indirect
26 +
	github.com/google/uuid v1.6.0 // indirect
17 27
	github.com/json-iterator/go v1.1.12 // indirect
18 28
	github.com/mattn/go-isatty v0.0.20 // indirect
19 29
	github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
21 31
	github.com/modern-go/reflect2 v1.0.2 // indirect
22 32
	github.com/ncruces/go-strftime v0.1.9 // indirect
23 33
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
34 +
	golang.org/x/crypto v0.39.0 // indirect
24 35
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
25 36
	golang.org/x/sys v0.33.0 // indirect
26 37
	golang.org/x/text v0.26.0 // indirect
apps/feeds-go/handlers_admin.go +46 −50
4 4
	"fmt"
5 5
	"net/http"
6 6
	"strings"
7 -
	"time"
8 7
9 -
	"github.com/google/uuid"
8 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
9 +
	"github.com/stevedylandev/andromeda/crates-go/web"
10 10
)
11 11
12 12
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
13 -
	a.render(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")})
13 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
14 14
}
15 15
16 16
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
17 17
	if a.AdminPassword == "" {
18 -
		http.Redirect(w, r, "/admin/login?error=No+admin+password+configured", http.StatusSeeOther)
18 +
		web.RedirectWithError(w, r, "/admin/login", "No admin password configured")
19 19
		return
20 20
	}
21 21
	if err := r.ParseForm(); err != nil {
22 -
		http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther)
22 +
		web.RedirectWithError(w, r, "/admin/login", "Bad request")
23 23
		return
24 24
	}
25 -
	if !verifyPassword(r.FormValue("password"), a.AdminPassword) {
26 -
		http.Redirect(w, r, "/admin/login?error=Invalid+password", http.StatusSeeOther)
25 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) {
26 +
		web.RedirectWithError(w, r, "/admin/login", "Invalid password")
27 27
		return
28 28
	}
29 -
	token := uuid.NewString()
30 -
	if err := createSession(a.DB, token, time.Now().Add(7*24*time.Hour)); err != nil {
29 +
	token, err := a.Sessions.Create()
30 +
	if err != nil {
31 31
		a.Log.Error("create session failed", "err", err)
32 -
		http.Redirect(w, r, "/admin/login?error=Session+error", http.StatusSeeOther)
32 +
		web.RedirectWithError(w, r, "/admin/login", "Session error")
33 33
		return
34 34
	}
35 -
	pruneExpiredSessions(a.DB)
36 -
	http.SetCookie(w, a.sessionCookie(token))
35 +
	a.Sessions.PruneExpired()
36 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
37 37
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
38 38
}
39 39
40 40
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
41 -
	if cookie, err := r.Cookie("feeds_session"); err == nil {
42 -
		deleteSession(a.DB, cookie.Value)
41 +
	if cookie, err := r.Cookie(a.Sessions.CookieName); err == nil {
42 +
		a.Sessions.Delete(cookie.Value)
43 43
	}
44 -
	http.SetCookie(w, &http.Cookie{Name: "feeds_session", Value: "", Path: "/", HttpOnly: true, Secure: a.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1})
44 +
	http.SetCookie(w, a.Sessions.ClearCookie())
45 45
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
46 46
}
47 47
56 56
	for _, s := range subs {
57 57
		rows = append(rows, adminSubRow{ID: s.ID, Title: s.Title, FeedURL: s.FeedURL, SiteURL: firstNonEmpty(nullStringValue(s.SiteURL), s.FeedURL), CategoryName: catMap[s.CategoryID.Int64], LastFetchedAt: nullStringValue(s.LastFetchedAt), LastError: nullStringValue(s.LastError)})
58 58
	}
59 -
	a.render(w, "admin.html", adminPageData{Success: r.URL.Query().Get("success"), Error: r.URL.Query().Get("error"), Subscriptions: rows, Categories: cats, PollIntervalMinutes: a.pollIntervalMinutes(), ItemCap: a.ItemCap, APIKeyConfigured: a.APIKey != ""})
59 +
	web.Render(a.Templates, w, "admin.html", adminPageData{Success: r.URL.Query().Get("success"), Error: r.URL.Query().Get("error"), Subscriptions: rows, Categories: cats, PollIntervalMinutes: a.pollIntervalMinutes(), ItemCap: a.ItemCap, APIKeyConfigured: a.APIKey != ""}, a.Log)
60 60
}
61 61
62 62
func (a *App) discoverFeedsHandler(w http.ResponseWriter, r *http.Request) {
63 63
	if err := r.ParseForm(); err != nil {
64 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "bad request"})
64 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "bad request"})
65 65
		return
66 66
	}
67 67
	feeds, err := discoverFeeds(r.Context(), r.FormValue("base_url"))
68 68
	if err != nil {
69 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
69 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
70 70
		return
71 71
	}
72 -
	writeJSON(w, http.StatusOK, feeds)
72 +
	web.WriteJSON(w, http.StatusOK, feeds)
73 73
}
74 74
75 75
func (a *App) addFeedHandler(w http.ResponseWriter, r *http.Request) {
76 76
	if err := r.ParseForm(); err != nil {
77 -
		redirectAdminError(w, r, "Bad request")
77 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
78 78
		return
79 79
	}
80 80
	body := createSubscriptionBody{FeedURL: r.FormValue("feed_url"), CategoryName: r.FormValue("category_name")}
81 81
	if _, err := a.createSubscription(r.Context(), body, true); err != nil {
82 82
		if isAlreadySubscribedError(err) {
83 -
			redirectAdminError(w, r, "Already subscribed")
83 +
			web.RedirectWithError(w, r, "/admin", "Already subscribed")
84 84
			return
85 85
		}
86 -
		redirectAdminError(w, r, "Failed to add feed")
86 +
		web.RedirectWithError(w, r, "/admin", "Failed to add feed")
87 87
		return
88 88
	}
89 -
	redirectAdminSuccess(w, r, "Feed added and will be fetched in the background")
89 +
	web.RedirectWithSuccess(w, r, "/admin", "Feed added and will be fetched in the background")
90 90
}
91 91
92 92
func (a *App) deleteFeedHandler(w http.ResponseWriter, r *http.Request) {
93 93
	id, ok := pathInt64(r, "id")
94 94
	if !ok {
95 -
		redirectAdminError(w, r, "Invalid feed ID")
95 +
		web.RedirectWithError(w, r, "/admin", "Invalid feed ID")
96 96
		return
97 97
	}
98 98
	deleted, err := deleteSubscription(a.DB, id)
99 -
	if err != nil {
100 -
		redirectAdminError(w, r, "Failed to remove")
101 -
		return
102 -
	}
103 -
	if !deleted {
104 -
		redirectAdminError(w, r, "Failed to remove")
99 +
	if err != nil || !deleted {
100 +
		web.RedirectWithError(w, r, "/admin", "Failed to remove")
105 101
		return
106 102
	}
107 -
	redirectAdminSuccess(w, r, "Feed removed")
103 +
	web.RedirectWithSuccess(w, r, "/admin", "Feed removed")
108 104
}
109 105
110 106
func (a *App) updateSubCategoryHandler(w http.ResponseWriter, r *http.Request) {
111 107
	id, ok := pathInt64(r, "id")
112 108
	if !ok {
113 -
		redirectAdminError(w, r, "Invalid feed ID")
109 +
		web.RedirectWithError(w, r, "/admin", "Invalid feed ID")
114 110
		return
115 111
	}
116 112
	if err := r.ParseForm(); err != nil {
117 -
		redirectAdminError(w, r, "Bad request")
113 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
118 114
		return
119 115
	}
120 116
	categoryID, err := a.resolveCategory(nil, r.FormValue("category_name"))
121 117
	if err != nil {
122 -
		redirectAdminError(w, r, "Failed to update category")
118 +
		web.RedirectWithError(w, r, "/admin", "Failed to update category")
123 119
		return
124 120
	}
125 121
	if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil {
126 -
		redirectAdminError(w, r, "Failed to update category")
122 +
		web.RedirectWithError(w, r, "/admin", "Failed to update category")
127 123
		return
128 124
	}
129 -
	redirectAdminSuccess(w, r, "Category updated")
125 +
	web.RedirectWithSuccess(w, r, "/admin", "Category updated")
130 126
}
131 127
132 128
func (a *App) addCategoryHandler(w http.ResponseWriter, r *http.Request) {
133 129
	if err := r.ParseForm(); err != nil {
134 -
		redirectAdminError(w, r, "Bad request")
130 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
135 131
		return
136 132
	}
137 133
	name := strings.TrimSpace(r.FormValue("name"))
138 134
	if name == "" {
139 -
		redirectAdminError(w, r, "Name required")
135 +
		web.RedirectWithError(w, r, "/admin", "Name required")
140 136
		return
141 137
	}
142 138
	if _, err := getOrCreateCategory(a.DB, name); err != nil {
143 -
		redirectAdminError(w, r, "Failed to add category")
139 +
		web.RedirectWithError(w, r, "/admin", "Failed to add category")
144 140
		return
145 141
	}
146 -
	redirectAdminSuccess(w, r, "Category added")
142 +
	web.RedirectWithSuccess(w, r, "/admin", "Category added")
147 143
}
148 144
149 145
func (a *App) deleteCategoryHandler(w http.ResponseWriter, r *http.Request) {
150 146
	id, ok := pathInt64(r, "id")
151 147
	if !ok {
152 -
		redirectAdminError(w, r, "Invalid category ID")
148 +
		web.RedirectWithError(w, r, "/admin", "Invalid category ID")
153 149
		return
154 150
	}
155 151
	deleted, err := deleteCategory(a.DB, id)
156 152
	if err != nil {
157 -
		redirectAdminError(w, r, "Failed to remove category")
153 +
		web.RedirectWithError(w, r, "/admin", "Failed to remove category")
158 154
		return
159 155
	}
160 156
	if !deleted {
161 -
		redirectAdminError(w, r, "Category not found")
157 +
		web.RedirectWithError(w, r, "/admin", "Category not found")
162 158
		return
163 159
	}
164 -
	redirectAdminSuccess(w, r, "Category removed")
160 +
	web.RedirectWithSuccess(w, r, "/admin", "Category removed")
165 161
}
166 162
167 163
func (a *App) importOPMLHandler(w http.ResponseWriter, r *http.Request) {
168 164
	summary, err := a.readAndImportOPML(r)
169 165
	if err != nil {
170 -
		redirectAdminError(w, r, "No file uploaded")
166 +
		web.RedirectWithError(w, r, "/admin", "No file uploaded")
171 167
		return
172 168
	}
173 -
	redirectAdminSuccess(w, r, fmt.Sprintf("Imported %d, skipped %d", summary.Imported, summary.Skipped))
169 +
	web.RedirectWithSuccess(w, r, "/admin", fmt.Sprintf("Imported %d, skipped %d", summary.Imported, summary.Skipped))
174 170
}
175 171
176 172
func (a *App) updateSettingsFormHandler(w http.ResponseWriter, r *http.Request) {
177 173
	if err := r.ParseForm(); err != nil {
178 -
		redirectAdminError(w, r, "Bad request")
174 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
179 175
		return
180 176
	}
181 177
	mins, ok := formPollMinutes(r)
182 178
	if !ok {
183 -
		redirectAdminError(w, r, "Interval must be 1-1440")
179 +
		web.RedirectWithError(w, r, "/admin", "Interval must be 1-1440")
184 180
		return
185 181
	}
186 182
	if err := setSetting(a.DB, "poll_interval_minutes", fmt.Sprintf("%d", mins)); err != nil {
187 -
		redirectAdminError(w, r, "Failed to save settings")
183 +
		web.RedirectWithError(w, r, "/admin", "Failed to save settings")
188 184
		return
189 185
	}
190 -
	redirectAdminSuccess(w, r, "Settings saved")
186 +
	web.RedirectWithSuccess(w, r, "/admin", "Settings saved")
191 187
}
apps/feeds-go/handlers_api.go +41 −39
2 2
3 3
import (
4 4
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/web"
5 7
)
6 8
7 9
func (a *App) listItemsAPI(w http.ResponseWriter, r *http.Request) {
8 10
	items, err := listItems(a.DB, itemFilterFromRequest(r))
9 11
	if err != nil {
10 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
12 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
11 13
		return
12 14
	}
13 -
	writeJSON(w, http.StatusOK, map[string]any{"items": items})
15 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
14 16
}
15 17
16 18
func (a *App) markItemReadAPI(isRead bool) http.HandlerFunc {
17 19
	return func(w http.ResponseWriter, r *http.Request) {
18 20
		id, ok := pathInt64(r, "id")
19 21
		if !ok {
20 -
			writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid item id"})
22 +
			web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid item id"})
21 23
			return
22 24
		}
23 25
		updated, err := markItemRead(a.DB, id, isRead)
24 26
		if err != nil {
25 -
			writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
27 +
			web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
26 28
			return
27 29
		}
28 30
		if !updated {
29 -
			writeJSON(w, http.StatusNotFound, map[string]any{"error": "item not found"})
31 +
			web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "item not found"})
30 32
			return
31 33
		}
32 -
		writeJSON(w, http.StatusOK, map[string]any{"ok": true, "is_read": isRead})
34 +
		web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "is_read": isRead})
33 35
	}
34 36
}
35 37
36 38
func (a *App) listSubscriptionsAPI(w http.ResponseWriter, r *http.Request) {
37 39
	subs, err := listSubscriptions(a.DB)
38 40
	if err != nil {
39 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
41 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
40 42
		return
41 43
	}
42 44
	views := make([]subscriptionView, 0, len(subs))
43 45
	for _, sub := range subs {
44 46
		views = append(views, toSubscriptionView(sub))
45 47
	}
46 -
	writeJSON(w, http.StatusOK, map[string]any{"subscriptions": views})
48 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"subscriptions": views})
47 49
}
48 50
49 51
func (a *App) createSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
50 52
	var body createSubscriptionBody
51 -
	if !decodeJSONBody(w, r, &body) {
53 +
	if !web.DecodeJSON(w, r, &body) {
52 54
		return
53 55
	}
54 56
	sub, err := a.createSubscription(r.Context(), body, false)
57 59
		if isAlreadySubscribedError(err) {
58 60
			status = http.StatusConflict
59 61
		}
60 -
		writeJSON(w, status, map[string]any{"error": err.Error()})
62 +
		web.WriteJSON(w, status, map[string]any{"error": err.Error()})
61 63
		return
62 64
	}
63 -
	writeJSON(w, http.StatusCreated, map[string]any{"subscription": toSubscriptionView(*sub)})
65 +
	web.WriteJSON(w, http.StatusCreated, map[string]any{"subscription": toSubscriptionView(*sub)})
64 66
}
65 67
66 68
func (a *App) updateSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
67 69
	id, ok := pathInt64(r, "id")
68 70
	if !ok {
69 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"})
71 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"})
70 72
		return
71 73
	}
72 74
	var body updateSubscriptionBody
73 -
	if !decodeJSONBody(w, r, &body) {
75 +
	if !web.DecodeJSON(w, r, &body) {
74 76
		return
75 77
	}
76 78
	categoryID, err := a.resolveSubscriptionCategory(body)
77 79
	if err != nil {
78 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
80 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
79 81
		return
80 82
	}
81 83
	if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil {
82 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
84 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
83 85
		return
84 86
	}
85 -
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
87 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
86 88
}
87 89
88 90
func (a *App) deleteSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
89 91
	id, ok := pathInt64(r, "id")
90 92
	if !ok {
91 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"})
93 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"})
92 94
		return
93 95
	}
94 96
	deleted, err := deleteSubscription(a.DB, id)
95 97
	if err != nil {
96 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
98 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
97 99
		return
98 100
	}
99 101
	if !deleted {
100 -
		writeJSON(w, http.StatusNotFound, map[string]any{"error": "subscription not found"})
102 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "subscription not found"})
101 103
		return
102 104
	}
103 -
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
105 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
104 106
}
105 107
106 108
func (a *App) listCategoriesAPI(w http.ResponseWriter, r *http.Request) {
107 109
	cats, err := listCategories(a.DB)
108 110
	if err != nil {
109 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
111 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
110 112
		return
111 113
	}
112 -
	writeJSON(w, http.StatusOK, map[string]any{"categories": cats})
114 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"categories": cats})
113 115
}
114 116
115 117
func (a *App) createCategoryAPI(w http.ResponseWriter, r *http.Request) {
116 118
	var body createCategoryBody
117 -
	if !decodeJSONBody(w, r, &body) {
119 +
	if !web.DecodeJSON(w, r, &body) {
118 120
		return
119 121
	}
120 122
	cat, err := getOrCreateCategory(a.DB, body.Name)
121 123
	if err != nil {
122 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
124 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
123 125
		return
124 126
	}
125 -
	writeJSON(w, http.StatusCreated, map[string]any{"category": cat})
127 +
	web.WriteJSON(w, http.StatusCreated, map[string]any{"category": cat})
126 128
}
127 129
128 130
func (a *App) deleteCategoryAPI(w http.ResponseWriter, r *http.Request) {
129 131
	id, ok := pathInt64(r, "id")
130 132
	if !ok {
131 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid category id"})
133 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid category id"})
132 134
		return
133 135
	}
134 136
	deleted, err := deleteCategory(a.DB, id)
135 137
	if err != nil {
136 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
138 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
137 139
		return
138 140
	}
139 141
	if !deleted {
140 -
		writeJSON(w, http.StatusNotFound, map[string]any{"error": "category not found"})
142 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "category not found"})
141 143
		return
142 144
	}
143 -
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
145 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
144 146
}
145 147
146 148
func (a *App) importOPMLAPI(w http.ResponseWriter, r *http.Request) {
147 149
	summary, err := a.readAndImportOPML(r)
148 150
	if err != nil {
149 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
151 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
150 152
		return
151 153
	}
152 -
	writeJSON(w, http.StatusOK, summary)
154 +
	web.WriteJSON(w, http.StatusOK, summary)
153 155
}
154 156
155 157
func (a *App) getSettingsAPI(w http.ResponseWriter, r *http.Request) {
156 -
	writeJSON(w, http.StatusOK, map[string]any{"poll_interval_minutes": a.pollIntervalMinutes(), "default_poll_minutes": a.DefaultPollMinutes, "item_cap_per_feed": a.ItemCap, "api_key_configured": a.APIKey != ""})
158 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"poll_interval_minutes": a.pollIntervalMinutes(), "default_poll_minutes": a.DefaultPollMinutes, "item_cap_per_feed": a.ItemCap, "api_key_configured": a.APIKey != ""})
157 159
}
158 160
159 161
func (a *App) updateSettingsAPI(w http.ResponseWriter, r *http.Request) {
160 162
	var body updateSettingsBody
161 -
	if !decodeJSONBody(w, r, &body) {
163 +
	if !web.DecodeJSON(w, r, &body) {
162 164
		return
163 165
	}
164 166
	if body.PollIntervalMinutes != nil {
165 167
		if !validPollMinutes(*body.PollIntervalMinutes) {
166 -
			writeJSON(w, http.StatusBadRequest, map[string]any{"error": "poll_interval_minutes must be between 1 and 1440"})
168 +
			web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "poll_interval_minutes must be between 1 and 1440"})
167 169
			return
168 170
		}
169 171
		if err := setSetting(a.DB, "poll_interval_minutes", itoa(*body.PollIntervalMinutes)); err != nil {
170 -
			writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
172 +
			web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
171 173
			return
172 174
		}
173 175
	}
174 -
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
176 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
175 177
}
176 178
177 179
func (a *App) discoverAPI(w http.ResponseWriter, r *http.Request) {
178 180
	var body discoverBody
179 -
	if !decodeJSONBody(w, r, &body) {
181 +
	if !web.DecodeJSON(w, r, &body) {
180 182
		return
181 183
	}
182 184
	feeds, err := discoverFeeds(r.Context(), body.BaseURL)
183 185
	if err != nil {
184 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
186 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
185 187
		return
186 188
	}
187 -
	writeJSON(w, http.StatusOK, map[string]any{"feeds": feeds})
189 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"feeds": feeds})
188 190
}
apps/feeds-go/handlers_public.go +8 −6
6 6
	"net/http"
7 7
	"strings"
8 8
	"time"
9 +
10 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 11
)
10 12
11 13
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
19 21
		data.FeedURLs = urls
20 22
		if len(urls) == 0 {
21 23
			data.Error = "No URLs provided"
22 -
			a.render(w, "index.html", data)
24 +
			web.Render(a.Templates, w, "index.html", data, a.Log)
23 25
			return
24 26
		}
25 27
		for _, item := range previewURLs(r.Context(), urls, a.Log) {
26 28
			data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
27 29
		}
28 -
		a.render(w, "index.html", data)
30 +
		web.Render(a.Templates, w, "index.html", data, a.Log)
29 31
		return
30 32
	}
31 33
33 35
	if err != nil {
34 36
		a.Log.Error("index query failed", "err", err)
35 37
		data.Error = "Error loading feeds. Please try again later."
36 -
		a.render(w, "index.html", data)
38 +
		web.Render(a.Templates, w, "index.html", data, a.Log)
37 39
		return
38 40
	}
39 41
	for _, item := range items {
43 45
		}
44 46
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: author, FormattedDate: formatDate(item.PublishedAt)})
45 47
	}
46 -
	a.render(w, "index.html", data)
48 +
	web.Render(a.Templates, w, "index.html", data, a.Log)
47 49
}
48 50
49 51
func (a *App) feedsExportHandler(w http.ResponseWriter, r *http.Request) {
64 66
				"htmlUrl": nullStringValue(s.SiteURL),
65 67
			})
66 68
		}
67 -
		writeJSON(w, http.StatusOK, map[string]any{"subscriptions": rows})
69 +
		web.WriteJSON(w, http.StatusOK, map[string]any{"subscriptions": rows})
68 70
	case "opml":
69 71
		a.writeOPMLExport(w, subs)
70 72
	default:
71 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid format. Use ?format=json or ?format=opml"})
73 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid format. Use ?format=json or ?format=opml"})
72 74
	}
73 75
}
74 76
apps/feeds-go/main.go +17 −8
7 7
	"log/slog"
8 8
	"net/http"
9 9
	"os"
10 -
	"strings"
10 +
11 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 +
	"github.com/stevedylandev/andromeda/crates-go/config"
11 13
)
12 14
13 15
func main() {
14 -
	loadDotEnv(".env")
16 +
	config.LoadDotEnv(".env")
15 17
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
16 18
17 -
	dbPath := getenv("FEEDS_DB_PATH", "feeds.sqlite")
19 +
	dbPath := config.Getenv("FEEDS_DB_PATH", "feeds.sqlite")
18 20
	db, err := openDB(dbPath)
19 21
	if err != nil {
20 22
		log.Fatal(err)
21 23
	}
22 24
	defer db.Close()
23 25
24 -
	defaultPoll := getenvInt("DEFAULT_POLL_MINUTES", 30)
25 -
	itemCap := getenvInt("ITEM_CAP_PER_FEED", 200)
26 +
	defaultPoll := config.GetenvInt("DEFAULT_POLL_MINUTES", 30)
27 +
	itemCap := config.GetenvInt("ITEM_CAP_PER_FEED", 200)
26 28
	if err := seedSettings(db, defaultPoll); err != nil {
27 29
		log.Fatal(err)
28 30
	}
29 31
32 +
	sessions := &auth.Store{DB: db, CookieName: "feeds_session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
33 +
	if err := sessions.EnsureSchema(); err != nil {
34 +
		log.Fatal(err)
35 +
	}
36 +
	sessions.PruneExpired()
37 +
30 38
	tmpl := template.Must(template.New("").Funcs(template.FuncMap{"safeURL": func(s string) string { return s }}).ParseFS(appFS, "templates/*.html"))
31 39
	app := &App{
32 40
		DB:                 db,
33 41
		Log:                logger,
34 42
		Templates:          tmpl,
43 +
		Sessions:           sessions,
35 44
		AdminPassword:      os.Getenv("ADMIN_PASSWORD"),
36 45
		APIKey:             os.Getenv("API_KEY"),
37 -
		CookieSecure:       strings.EqualFold(os.Getenv("COOKIE_SECURE"), "true"),
38 -
		BaseURL:            getenv("BASE_URL", "http://localhost:3000"),
46 +
		CookieSecure:       sessions.CookieSecure,
47 +
		BaseURL:            config.Getenv("BASE_URL", "http://localhost:3000"),
39 48
		DefaultPollMinutes: defaultPoll,
40 49
		ItemCap:            itemCap,
41 50
	}
44 53
	}
45 54
	go app.poller(context.Background())
46 55
47 -
	addr := getenv("HOST", "0.0.0.0") + ":" + getenv("PORT", "3000")
56 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
48 57
	logger.Info("feeds-go server running", "addr", addr)
49 58
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
50 59
		log.Fatal(err)
apps/feeds-go/middleware.go +1 −43
1 1
package main
2 2
3 -
import (
4 -
	"net/http"
5 -
	"strings"
6 -
)
7 -
8 -
func (a *App) requireSession(next http.HandlerFunc) http.HandlerFunc {
9 -
	return func(w http.ResponseWriter, r *http.Request) {
10 -
		if !a.hasValidSession(r) {
11 -
			http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
12 -
			return
13 -
		}
14 -
		next(w, r)
15 -
	}
16 -
}
17 -
18 -
func (a *App) requireAPIAuth(next http.HandlerFunc) http.HandlerFunc {
19 -
	return func(w http.ResponseWriter, r *http.Request) {
20 -
		if a.APIKey != "" {
21 -
			authz := r.Header.Get("Authorization")
22 -
			if strings.HasPrefix(strings.ToLower(authz), "bearer ") && verifyAPIKey(strings.TrimSpace(authz[7:]), a.APIKey) {
23 -
				next(w, r)
24 -
				return
25 -
			}
26 -
		}
27 -
		if a.hasValidSession(r) {
28 -
			next(w, r)
29 -
			return
30 -
		}
31 -
		writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
32 -
	}
33 -
}
34 -
35 -
func (a *App) hasValidSession(r *http.Request) bool {
36 -
	cookie, err := r.Cookie("feeds_session")
37 -
	if err != nil || cookie.Value == "" {
38 -
		return false
39 -
	}
40 -
	return isValidSession(a.DB, cookie.Value)
41 -
}
42 -
43 -
func (a *App) sessionCookie(token string) *http.Cookie {
44 -
	return &http.Cookie{Name: "feeds_session", Value: token, Path: "/", HttpOnly: true, Secure: a.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: 7 * 24 * 60 * 60}
45 -
}
3 +
import "net/http"
46 4
47 5
func (a *App) withCORS(next http.HandlerFunc) http.HandlerFunc {
48 6
	return func(w http.ResponseWriter, r *http.Request) {
apps/feeds-go/routes.go +35 −22
1 1
package main
2 2
3 -
import "net/http"
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
7 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
4 10
5 11
func (a *App) routes() *http.ServeMux {
6 12
	mux := http.NewServeMux()
8 14
	mux.HandleFunc("GET /", a.indexHandler)
9 15
	mux.HandleFunc("GET /feeds", a.feedsExportHandler)
10 16
	mux.HandleFunc("GET /feed.xml", a.atomFeedHandler)
11 -
	mux.HandleFunc("GET /static/", a.embeddedHandler("static"))
12 -
	mux.HandleFunc("GET /assets/", a.embeddedHandler("assets"))
17 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
18 +
	darkmatter.Mount(mux, "/assets")
19 +
20 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
21 +
		return a.Sessions.RequireSession("/admin/login", next)
22 +
	}
23 +
	requireAPIAuth := func(next http.HandlerFunc) http.HandlerFunc {
24 +
		return auth.RequireBearerOrSession(a.Sessions, a.APIKey, next)
25 +
	}
13 26
14 27
	mux.HandleFunc("GET /admin/login", a.loginGetHandler)
15 28
	mux.HandleFunc("POST /admin/login", a.loginPostHandler)
16 29
	mux.HandleFunc("GET /admin/logout", a.logoutHandler)
17 -
	mux.HandleFunc("GET /admin", a.requireSession(a.adminHandler))
18 -
	mux.HandleFunc("POST /admin/add-feed", a.requireSession(a.addFeedHandler))
19 -
	mux.HandleFunc("POST /admin/feeds/{id}/delete", a.requireSession(a.deleteFeedHandler))
20 -
	mux.HandleFunc("POST /admin/feeds/{id}/category", a.requireSession(a.updateSubCategoryHandler))
21 -
	mux.HandleFunc("POST /admin/categories", a.requireSession(a.addCategoryHandler))
22 -
	mux.HandleFunc("POST /admin/categories/{id}/delete", a.requireSession(a.deleteCategoryHandler))
23 -
	mux.HandleFunc("POST /admin/import-opml", a.requireSession(a.importOPMLHandler))
24 -
	mux.HandleFunc("POST /admin/settings", a.requireSession(a.updateSettingsFormHandler))
25 -
	mux.HandleFunc("POST /admin/discover-feeds", a.requireSession(a.discoverFeedsHandler))
30 +
	mux.HandleFunc("GET /admin", requireSession(a.adminHandler))
31 +
	mux.HandleFunc("POST /admin/add-feed", requireSession(a.addFeedHandler))
32 +
	mux.HandleFunc("POST /admin/feeds/{id}/delete", requireSession(a.deleteFeedHandler))
33 +
	mux.HandleFunc("POST /admin/feeds/{id}/category", requireSession(a.updateSubCategoryHandler))
34 +
	mux.HandleFunc("POST /admin/categories", requireSession(a.addCategoryHandler))
35 +
	mux.HandleFunc("POST /admin/categories/{id}/delete", requireSession(a.deleteCategoryHandler))
36 +
	mux.HandleFunc("POST /admin/import-opml", requireSession(a.importOPMLHandler))
37 +
	mux.HandleFunc("POST /admin/settings", requireSession(a.updateSettingsFormHandler))
38 +
	mux.HandleFunc("POST /admin/discover-feeds", requireSession(a.discoverFeedsHandler))
26 39
27 40
	mux.HandleFunc("GET /api/items", a.withCORS(a.listItemsAPI))
28 -
	mux.HandleFunc("POST /api/items/{id}/read", a.withCORS(a.requireAPIAuth(a.markItemReadAPI(true))))
29 -
	mux.HandleFunc("POST /api/items/{id}/unread", a.withCORS(a.requireAPIAuth(a.markItemReadAPI(false))))
41 +
	mux.HandleFunc("POST /api/items/{id}/read", a.withCORS(requireAPIAuth(a.markItemReadAPI(true))))
42 +
	mux.HandleFunc("POST /api/items/{id}/unread", a.withCORS(requireAPIAuth(a.markItemReadAPI(false))))
30 43
	mux.HandleFunc("GET /api/subscriptions", a.withCORS(a.listSubscriptionsAPI))
31 -
	mux.HandleFunc("POST /api/subscriptions", a.withCORS(a.requireAPIAuth(a.createSubscriptionAPI)))
32 -
	mux.HandleFunc("PATCH /api/subscriptions/{id}", a.withCORS(a.requireAPIAuth(a.updateSubscriptionAPI)))
33 -
	mux.HandleFunc("DELETE /api/subscriptions/{id}", a.withCORS(a.requireAPIAuth(a.deleteSubscriptionAPI)))
44 +
	mux.HandleFunc("POST /api/subscriptions", a.withCORS(requireAPIAuth(a.createSubscriptionAPI)))
45 +
	mux.HandleFunc("PATCH /api/subscriptions/{id}", a.withCORS(requireAPIAuth(a.updateSubscriptionAPI)))
46 +
	mux.HandleFunc("DELETE /api/subscriptions/{id}", a.withCORS(requireAPIAuth(a.deleteSubscriptionAPI)))
34 47
	mux.HandleFunc("GET /api/categories", a.withCORS(a.listCategoriesAPI))
35 -
	mux.HandleFunc("POST /api/categories", a.withCORS(a.requireAPIAuth(a.createCategoryAPI)))
36 -
	mux.HandleFunc("DELETE /api/categories/{id}", a.withCORS(a.requireAPIAuth(a.deleteCategoryAPI)))
37 -
	mux.HandleFunc("POST /api/import/opml", a.withCORS(a.requireAPIAuth(a.importOPMLAPI)))
48 +
	mux.HandleFunc("POST /api/categories", a.withCORS(requireAPIAuth(a.createCategoryAPI)))
49 +
	mux.HandleFunc("DELETE /api/categories/{id}", a.withCORS(requireAPIAuth(a.deleteCategoryAPI)))
50 +
	mux.HandleFunc("POST /api/import/opml", a.withCORS(requireAPIAuth(a.importOPMLAPI)))
38 51
	mux.HandleFunc("GET /api/settings", a.withCORS(a.getSettingsAPI))
39 -
	mux.HandleFunc("PUT /api/settings", a.withCORS(a.requireAPIAuth(a.updateSettingsAPI)))
40 -
	mux.HandleFunc("POST /api/discover", a.withCORS(a.requireAPIAuth(a.discoverAPI)))
52 +
	mux.HandleFunc("PUT /api/settings", a.withCORS(requireAPIAuth(a.updateSettingsAPI)))
53 +
	mux.HandleFunc("POST /api/discover", a.withCORS(requireAPIAuth(a.discoverAPI)))
41 54
42 55
	return mux
43 56
}
apps/feeds-go/util.go +0 −34
4 4
	"database/sql"
5 5
	"fmt"
6 6
	"net/http"
7 -
	"os"
8 7
	"strconv"
9 8
	"strings"
10 9
	"time"
23 22
		return ""
24 23
	}
25 24
	return time.Unix(ts, 0).UTC().Format("Jan 2, 2006")
26 -
}
27 -
28 -
func getenv(key, fallback string) string {
29 -
	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
30 -
		return v
31 -
	}
32 -
	return fallback
33 -
}
34 -
35 -
func getenvInt(key string, fallback int) int {
36 -
	if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(key))); err == nil {
37 -
		return v
38 -
	}
39 -
	return fallback
40 25
}
41 26
42 27
func parseIntDefault(s string, fallback int) int {
118 103
		}
119 104
		return nil
120 105
	}(), ETag: nullStringPointer(s.ETag), LastModified: nullStringPointer(s.LastModified), LastFetchedAt: nullStringPointer(s.LastFetchedAt), LastError: nullStringPointer(s.LastError), AddedAt: s.AddedAt}
121 -
}
122 -
123 -
func loadDotEnv(path string) {
124 -
	data, err := os.ReadFile(path)
125 -
	if err != nil {
126 -
		return
127 -
	}
128 -
	for _, line := range strings.Split(string(data), "\n") {
129 -
		line = strings.TrimSpace(line)
130 -
		if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
131 -
			continue
132 -
		}
133 -
		parts := strings.SplitN(line, "=", 2)
134 -
		key := strings.TrimSpace(parts[0])
135 -
		val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
136 -
		if os.Getenv(key) == "" {
137 -
			_ = os.Setenv(key, val)
138 -
		}
139 -
	}
140 106
}
141 107
142 108
func itoa(v int) string {
apps/feeds-go/web.go (deleted) +0 −68
1 -
package main
2 -
3 -
import (
4 -
	"encoding/json"
5 -
	"mime"
6 -
	"net/http"
7 -
	"net/url"
8 -
	"path/filepath"
9 -
	"strings"
10 -
11 -
	"golang.org/x/crypto/bcrypt"
12 -
)
13 -
14 -
func (a *App) embeddedHandler(prefix string) http.HandlerFunc {
15 -
	return func(w http.ResponseWriter, r *http.Request) {
16 -
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
17 -
		path := filepath.ToSlash(filepath.Join(prefix, name))
18 -
		data, err := appFS.ReadFile(path)
19 -
		if err != nil {
20 -
			http.NotFound(w, r)
21 -
			return
22 -
		}
23 -
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
24 -
			w.Header().Set("Content-Type", ct)
25 -
		}
26 -
		_, _ = w.Write(data)
27 -
	}
28 -
}
29 -
30 -
func (a *App) render(w http.ResponseWriter, name string, data any) {
31 -
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
32 -
	if err := a.Templates.ExecuteTemplate(w, name, data); err != nil {
33 -
		a.Log.Error("template render failed", "name", name, "err", err)
34 -
		http.Error(w, "template error", http.StatusInternalServerError)
35 -
	}
36 -
}
37 -
38 -
func writeJSON(w http.ResponseWriter, status int, data any) {
39 -
	w.Header().Set("Content-Type", "application/json")
40 -
	w.WriteHeader(status)
41 -
	_ = json.NewEncoder(w).Encode(data)
42 -
}
43 -
44 -
func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool {
45 -
	defer r.Body.Close()
46 -
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
47 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON"})
48 -
		return false
49 -
	}
50 -
	return true
51 -
}
52 -
53 -
func verifyPassword(input, expected string) bool {
54 -
	if strings.HasPrefix(expected, "$2") {
55 -
		return bcrypt.CompareHashAndPassword([]byte(expected), []byte(input)) == nil
56 -
	}
57 -
	return input == expected
58 -
}
59 -
60 -
func verifyAPIKey(input, expected string) bool { return input == expected }
61 -
62 -
func redirectAdminError(w http.ResponseWriter, r *http.Request, msg string) {
63 -
	http.Redirect(w, r, "/admin?error="+url.QueryEscape(msg), http.StatusSeeOther)
64 -
}
65 -
66 -
func redirectAdminSuccess(w http.ResponseWriter, r *http.Request, msg string) {
67 -
	http.Redirect(w, r, "/admin?success="+url.QueryEscape(msg), http.StatusSeeOther)
68 -
}
apps/jotts-go/app.go +4 −1
5 5
	"embed"
6 6
	"html/template"
7 7
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 10
)
9 11
10 -
//go:embed templates/*.html static/* static/fonts/* assets/* assets/fonts/*
12 +
//go:embed templates/*.html static/*
11 13
var appFS embed.FS
12 14
13 15
type App struct {
14 16
	DB           *sql.DB
15 17
	Log          *slog.Logger
16 18
	Templates    *template.Template
19 +
	Sessions     *auth.Store
17 20
	Password     string
18 21
	APIKey       string
19 22
	CookieSecure bool
apps/jotts-go/assets/darkmatter.css (deleted) +0 −648
1 -
/* Darkmatter — canonical CSS for Andromeda apps.
2 -
 * Source of truth for reset, tokens, and shared components.
3 -
 * Docs: /darkmatter
4 -
 */
5 -
6 -
@font-face {
7 -
  font-family: "Commit Mono";
8 -
  src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype");
9 -
  font-weight: 400;
10 -
  font-style: normal;
11 -
  font-display: swap;
12 -
}
13 -
14 -
@font-face {
15 -
  font-family: "Commit Mono";
16 -
  src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype");
17 -
  font-weight: 700;
18 -
  font-style: normal;
19 -
  font-display: swap;
20 -
}
21 -
22 -
/* ── Reset + webkit hardening ─────────────────────────────────────── */
23 -
24 -
*,
25 -
*::before,
26 -
*::after {
27 -
  padding: 0;
28 -
  margin: 0;
29 -
  box-sizing: border-box;
30 -
  font-family: "Commit Mono", monospace, sans-serif;
31 -
  -webkit-tap-highlight-color: transparent;
32 -
}
33 -
34 -
* {
35 -
  scrollbar-width: none;
36 -
  -ms-overflow-style: none;
37 -
}
38 -
39 -
html {
40 -
  background: #121113;
41 -
  color: #ffffff;
42 -
  font-size: 14px;
43 -
  line-height: 1.6;
44 -
  -webkit-text-size-adjust: 100%;
45 -
  text-size-adjust: 100%;
46 -
}
47 -
48 -
html::-webkit-scrollbar {
49 -
  display: none;
50 -
}
51 -
52 -
body {
53 -
  display: flex;
54 -
  flex-direction: column;
55 -
  justify-content: start;
56 -
  align-items: start;
57 -
  gap: 1.5rem;
58 -
  min-height: 100vh;
59 -
  max-width: 700px;
60 -
  margin: auto;
61 -
  padding: 0 1rem 4rem;
62 -
}
63 -
64 -
@media (max-width: 480px) {
65 -
  body {
66 -
    padding: 1rem;
67 -
    gap: 1rem;
68 -
  }
69 -
}
70 -
71 -
/* ── Links ────────────────────────────────────────────────────────── */
72 -
73 -
a {
74 -
  color: #ffffff;
75 -
  text-decoration: none;
76 -
  touch-action: manipulation;
77 -
}
78 -
79 -
a:hover {
80 -
  opacity: 0.7;
81 -
}
82 -
83 -
/* ── Header / nav ─────────────────────────────────────────────────── */
84 -
85 -
.header {
86 -
  display: flex;
87 -
  flex-direction: column;
88 -
  gap: 0.5rem;
89 -
  width: 100%;
90 -
  margin-top: 2rem;
91 -
  border-bottom: 1px solid #333;
92 -
  padding-bottom: 1rem;
93 -
}
94 -
95 -
.logo {
96 -
  font-size: 28px;
97 -
  font-weight: 700;
98 -
  text-decoration: none;
99 -
  text-transform: uppercase;
100 -
}
101 -
102 -
.links {
103 -
  display: flex;
104 -
  align-items: center;
105 -
  gap: 0.75rem;
106 -
  font-size: 12px;
107 -
}
108 -
109 -
/* ── Main ─────────────────────────────────────────────────────────── */
110 -
111 -
main {
112 -
  width: 100%;
113 -
  display: flex;
114 -
  flex-direction: column;
115 -
  gap: 1rem;
116 -
}
117 -
118 -
/* ── Forms ────────────────────────────────────────────────────────── */
119 -
120 -
.form {
121 -
  display: flex;
122 -
  flex-direction: column;
123 -
  gap: 0.5rem;
124 -
  width: 100%;
125 -
}
126 -
127 -
.form-row {
128 -
  display: flex;
129 -
  gap: 0.5rem;
130 -
  width: 100%;
131 -
}
132 -
133 -
.form-row .form-field {
134 -
  flex: 1;
135 -
}
136 -
137 -
.form-field {
138 -
  display: flex;
139 -
  flex-direction: column;
140 -
  gap: 0.25rem;
141 -
}
142 -
143 -
.form-actions {
144 -
  display: flex;
145 -
  gap: 0.5rem;
146 -
}
147 -
148 -
@media (max-width: 480px) {
149 -
  .form-row {
150 -
    flex-direction: column;
151 -
  }
152 -
}
153 -
154 -
label {
155 -
  font-size: 12px;
156 -
  opacity: 0.7;
157 -
}
158 -
159 -
input,
160 -
textarea,
161 -
select {
162 -
  background: #121113;
163 -
  color: #ffffff;
164 -
  border: 1px solid #ffffff;
165 -
  padding: 0.4rem 0.75rem;
166 -
  font-size: 16px; /* 16px prevents iOS focus zoom */
167 -
  width: 100%;
168 -
  border-radius: 0;
169 -
  -webkit-appearance: none;
170 -
  appearance: none;
171 -
  outline: none;
172 -
}
173 -
174 -
input:focus,
175 -
textarea:focus,
176 -
select:focus {
177 -
  outline: none;
178 -
}
179 -
180 -
textarea {
181 -
  min-height: 400px;
182 -
  resize: vertical;
183 -
}
184 -
185 -
select {
186 -
  background-image: none;
187 -
  padding-right: 0.75rem;
188 -
}
189 -
190 -
input[type="file"] {
191 -
  cursor: pointer;
192 -
  font-size: 14px;
193 -
}
194 -
195 -
input[type="file"]::-webkit-file-upload-button,
196 -
input[type="file"]::file-selector-button {
197 -
  background: #121113;
198 -
  color: #ffffff;
199 -
  border: 1px solid #555;
200 -
  padding: 0.25rem 0.5rem;
201 -
  cursor: pointer;
202 -
  font-family: "Commit Mono", monospace;
203 -
  font-size: 12px;
204 -
  margin-right: 0.5rem;
205 -
  border-radius: 0;
206 -
}
207 -
208 -
input[type="search"]::-webkit-search-decoration,
209 -
input[type="search"]::-webkit-search-cancel-button,
210 -
input[type="search"]::-webkit-search-results-button,
211 -
input[type="search"]::-webkit-search-results-decoration {
212 -
  -webkit-appearance: none;
213 -
}
214 -
215 -
input[type="checkbox"],
216 -
input[type="radio"] {
217 -
  -webkit-appearance: none;
218 -
  appearance: none;
219 -
  width: 16px;
220 -
  height: 16px;
221 -
  background: transparent;
222 -
  border: 1px solid #ffffff;
223 -
  border-radius: 0;
224 -
  padding: 0;
225 -
  cursor: pointer;
226 -
  position: relative;
227 -
  flex-shrink: 0;
228 -
  touch-action: manipulation;
229 -
}
230 -
231 -
input[type="radio"] {
232 -
  border-radius: 50%;
233 -
}
234 -
235 -
input[type="checkbox"]:checked::after {
236 -
  content: '✔︎';
237 -
  position: absolute;
238 -
  top: 50%;
239 -
  left: 50%;
240 -
  transform: translate(-50%, -50%);
241 -
  font-size: 12px;
242 -
  color: #ffffff;
243 -
  line-height: 1;
244 -
}
245 -
246 -
input[type="radio"]:checked::after {
247 -
  content: '';
248 -
  position: absolute;
249 -
  top: 50%;
250 -
  left: 50%;
251 -
  width: 8px;
252 -
  height: 8px;
253 -
  border-radius: 50%;
254 -
  background: #ffffff;
255 -
  transform: translate(-50%, -50%);
256 -
}
257 -
258 -
.checkbox-field {
259 -
  justify-content: flex-end;
260 -
}
261 -
262 -
.checkbox-field label {
263 -
  display: flex;
264 -
  align-items: center;
265 -
  gap: 0.5rem;
266 -
  font-size: 14px;
267 -
  opacity: 1;
268 -
  cursor: pointer;
269 -
}
270 -
271 -
/* Switch */
272 -
273 -
.switch-row {
274 -
  display: flex;
275 -
  align-items: center;
276 -
  gap: 0.5rem;
277 -
}
278 -
279 -
.switch-label {
280 -
  font-size: 14px;
281 -
}
282 -
283 -
.switch {
284 -
  position: relative;
285 -
  display: inline-block;
286 -
  width: 36px;
287 -
  height: 20px;
288 -
  flex-shrink: 0;
289 -
}
290 -
291 -
.switch input {
292 -
  opacity: 0;
293 -
  width: 0;
294 -
  height: 0;
295 -
}
296 -
297 -
.switch-slider {
298 -
  position: absolute;
299 -
  cursor: pointer;
300 -
  top: 0;
301 -
  left: 0;
302 -
  right: 0;
303 -
  bottom: 0;
304 -
  background: #333;
305 -
  border-radius: 20px;
306 -
  transition: background 0.2s;
307 -
}
308 -
309 -
.switch-slider::before {
310 -
  content: "";
311 -
  position: absolute;
312 -
  height: 14px;
313 -
  width: 14px;
314 -
  left: 3px;
315 -
  bottom: 3px;
316 -
  background: #888;
317 -
  border-radius: 50%;
318 -
  transition: transform 0.2s, background 0.2s;
319 -
}
320 -
321 -
.switch input:checked + .switch-slider {
322 -
  background: #555;
323 -
}
324 -
325 -
.switch input:checked + .switch-slider::before {
326 -
  transform: translateX(16px);
327 -
  background: #ffffff;
328 -
}
329 -
330 -
/* ── Buttons ──────────────────────────────────────────────────────── */
331 -
332 -
button,
333 -
.btn {
334 -
  background: #121113;
335 -
  color: #ffffff;
336 -
  padding: 0.2rem 0.75rem;
337 -
  border: 1px solid #ffffff;
338 -
  cursor: pointer;
339 -
  width: fit-content;
340 -
  font-size: 14px;
341 -
  line-height: 1.4;
342 -
  border-radius: 0;
343 -
  -webkit-appearance: none;
344 -
  appearance: none;
345 -
  text-decoration: none;
346 -
  display: inline-block;
347 -
  touch-action: manipulation;
348 -
}
349 -
350 -
button:hover,
351 -
.btn:hover {
352 -
  opacity: 0.7;
353 -
}
354 -
355 -
button.loading {
356 -
  cursor: wait;
357 -
}
358 -
359 -
.link-button {
360 -
  background: none;
361 -
  border: none;
362 -
  color: #ffffff;
363 -
  cursor: pointer;
364 -
  font-size: 12px;
365 -
  padding: 0;
366 -
  font-family: inherit;
367 -
  -webkit-appearance: none;
368 -
  appearance: none;
369 -
}
370 -
371 -
.link-button:hover {
372 -
  opacity: 0.7;
373 -
}
374 -
375 -
.link-button.danger {
376 -
  opacity: 0.5;
377 -
}
378 -
379 -
.link-button.danger:hover {
380 -
  opacity: 0.3;
381 -
}
382 -
383 -
.inline-form {
384 -
  display: inline;
385 -
  margin: 0;
386 -
  padding: 0;
387 -
}
388 -
389 -
/* ── Feedback ─────────────────────────────────────────────────────── */
390 -
391 -
.error {
392 -
  color: #ffffff;
393 -
  border-left: 2px solid #ffffff;
394 -
  padding-left: 0.5rem;
395 -
  font-size: 13px;
396 -
  opacity: 0.8;
397 -
}
398 -
399 -
.success {
400 -
  color: #ffffff;
401 -
  border-left: 2px solid #555;
402 -
  padding-left: 0.5rem;
403 -
  font-size: 13px;
404 -
  opacity: 0.7;
405 -
}
406 -
407 -
.empty {
408 -
  opacity: 0.5;
409 -
  font-size: 12px;
410 -
}
411 -
412 -
/* ── Item list (generic stacked list pattern) ────────────────────── */
413 -
414 -
.item-list {
415 -
  display: flex;
416 -
  flex-direction: column;
417 -
  width: 100%;
418 -
}
419 -
420 -
.item {
421 -
  display: flex;
422 -
  flex-direction: column;
423 -
  gap: 0.25rem;
424 -
  padding: 0.75rem 0;
425 -
  border-bottom: 1px solid #333;
426 -
  min-width: 0;
427 -
}
428 -
429 -
.item:hover {
430 -
  opacity: 0.7;
431 -
}
432 -
433 -
.item-title {
434 -
  display: grid;
435 -
  grid-template-columns: auto minmax(0, 1fr);
436 -
  align-items: center;
437 -
  gap: 0.4rem;
438 -
  min-width: 0;
439 -
  max-width: 100%;
440 -
  font-size: 16px;
441 -
  overflow-wrap: anywhere;
442 -
}
443 -
444 -
.item-meta {
445 -
  max-width: 100%;
446 -
  font-size: 12px;
447 -
  opacity: 0.5;
448 -
  overflow-wrap: anywhere;
449 -
  word-break: break-word;
450 -
}
451 -
452 -
.favicon {
453 -
  flex-shrink: 0;
454 -
}
455 -
456 -
/* ── Admin list (horizontal row w/ actions) ──────────────────────── */
457 -
458 -
.admin-list {
459 -
  display: flex;
460 -
  flex-direction: column;
461 -
  width: 100%;
462 -
}
463 -
464 -
.admin-list-item {
465 -
  display: flex;
466 -
  justify-content: space-between;
467 -
  align-items: center;
468 -
  padding: 8px 0;
469 -
  border-bottom: 1px solid #333;
470 -
  gap: 1rem;
471 -
  min-width: 0;
472 -
}
473 -
474 -
.admin-list-info {
475 -
  display: flex;
476 -
  flex: 1;
477 -
  flex-direction: column;
478 -
  gap: 0.2rem;
479 -
  min-width: 0;
480 -
}
481 -
482 -
.admin-list-title {
483 -
  display: grid;
484 -
  grid-template-columns: auto minmax(0, 1fr);
485 -
  align-items: center;
486 -
  gap: 0.4rem;
487 -
  min-width: 0;
488 -
  max-width: 100%;
489 -
  font-size: 15px;
490 -
  white-space: normal;
491 -
  overflow: visible;
492 -
  text-overflow: clip;
493 -
  overflow-wrap: anywhere;
494 -
}
495 -
496 -
.admin-list-meta {
497 -
  display: flex;
498 -
  gap: 0.75rem;
499 -
  align-items: center;
500 -
}
501 -
502 -
.admin-list-date {
503 -
  font-size: 11px;
504 -
  opacity: 0.4;
505 -
}
506 -
507 -
.admin-list-actions {
508 -
  display: flex;
509 -
  gap: 1rem;
510 -
  font-size: 12px;
511 -
  flex-shrink: 0;
512 -
  flex-wrap: wrap;
513 -
}
514 -
515 -
.admin-toolbar {
516 -
  display: flex;
517 -
  justify-content: space-between;
518 -
  align-items: center;
519 -
  width: 100%;
520 -
}
521 -
522 -
.admin-toolbar h2 {
523 -
  font-size: 18px;
524 -
  font-weight: 700;
525 -
}
526 -
527 -
@media (max-width: 480px) {
528 -
  .admin-list-item {
529 -
    flex-direction: column;
530 -
    align-items: flex-start;
531 -
    gap: 0.5rem;
532 -
  }
533 -
}
534 -
535 -
/* ── Tags / badges ───────────────────────────────────────────────── */
536 -
537 -
.tag {
538 -
  font-size: 11px;
539 -
  opacity: 0.5;
540 -
  background: #1e1c1f;
541 -
  padding: 1px 6px;
542 -
  border: 1px solid #333;
543 -
}
544 -
545 -
.status-badge {
546 -
  font-size: 11px;
547 -
  padding: 1px 6px;
548 -
  border: 1px solid #333;
549 -
}
550 -
551 -
.status-published {
552 -
  opacity: 1;
553 -
  border-color: #555;
554 -
}
555 -
556 -
.status-draft {
557 -
  opacity: 0.4;
558 -
}
559 -
560 -
/* ── Tables ──────────────────────────────────────────────────────── */
561 -
562 -
table {
563 -
  width: 100%;
564 -
  border-collapse: collapse;
565 -
}
566 -
567 -
th {
568 -
  opacity: 0.5;
569 -
  font-weight: 400;
570 -
  font-size: 12px;
571 -
  text-transform: uppercase;
572 -
  text-align: left;
573 -
  padding: 6px;
574 -
  border-bottom: 1px solid #333;
575 -
}
576 -
577 -
td {
578 -
  padding: 6px;
579 -
  border-bottom: 1px solid #333;
580 -
}
581 -
582 -
/* ── Spinner (braille) ────────────────────────────────────────────── */
583 -
584 -
.spinner {
585 -
  margin-left: 0.6rem;
586 -
}
587 -
588 -
.spinner::after {
589 -
  content: "⠋";
590 -
  display: inline-block;
591 -
  animation: braille-spin 0.8s steps(10) infinite;
592 -
}
593 -
594 -
@keyframes braille-spin {
595 -
  0%   { content: "⠋"; }
596 -
  10%  { content: "⠙"; }
597 -
  20%  { content: "⠹"; }
598 -
  30%  { content: "⠸"; }
599 -
  40%  { content: "⠼"; }
600 -
  50%  { content: "⠴"; }
601 -
  60%  { content: "⠦"; }
602 -
  70%  { content: "⠧"; }
603 -
  80%  { content: "⠇"; }
604 -
  90%  { content: "⠏"; }
605 -
}
606 -
607 -
/* ── Inline code ─────────────────────────────────────────────────── */
608 -
609 -
code {
610 -
  background: #1e1c1f;
611 -
  padding: 2px 4px;
612 -
  font-size: 13px;
613 -
}
614 -
615 -
pre {
616 -
  background: #1e1c1f;
617 -
  padding: 12px;
618 -
  overflow-x: auto;
619 -
  border: 1px solid #333;
620 -
  -webkit-overflow-scrolling: touch;
621 -
}
622 -
623 -
pre code {
624 -
  background: none;
625 -
  padding: 0;
626 -
}
627 -
628 -
/* ── Footer ──────────────────────────────────────────────────────── */
629 -
630 -
.footer {
631 -
  width: 100%;
632 -
  border-top: 1px solid #333;
633 -
  padding-top: 1rem;
634 -
  margin-top: auto;
635 -
  display: flex;
636 -
  justify-content: center;
637 -
}
638 -
639 -
/* ── Utility ─────────────────────────────────────────────────────── */
640 -
641 -
.hidden {
642 -
  display: none;
643 -
}
644 -
645 -
.scroll-x {
646 -
  overflow-x: auto;
647 -
  -webkit-overflow-scrolling: touch;
648 -
}
apps/jotts-go/assets/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/assets/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/db.go +2 −30
3 3
import (
4 4
	"database/sql"
5 5
	"errors"
6 -
	"time"
7 6
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 8
	_ "modernc.org/sqlite"
9 9
)
10 10
18 18
    content    TEXT NOT NULL,
19 19
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
20 20
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21 -
);
22 -
23 -
CREATE TABLE IF NOT EXISTS sessions (
24 -
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
25 -
    token      TEXT NOT NULL UNIQUE,
26 -
    expires_at TEXT NOT NULL
27 21
);
28 22
`
29 23
56 50
}
57 51
58 52
func createNote(db *sql.DB, title, content string) (*Note, error) {
59 -
	shortID, err := generateShortID(10)
53 +
	shortID, err := auth.GenerateShortID(10)
60 54
	if err != nil {
61 55
		return nil, err
62 56
	}
113 107
	return n > 0, nil
114 108
}
115 109
116 -
func createSession(db *sql.DB, token string, expiresAt time.Time) error {
117 -
	_, err := db.Exec(`INSERT INTO sessions (token, expires_at) VALUES (?, ?)`, token, expiresAt.UTC().Format("2006-01-02 15:04:05"))
118 -
	return err
119 -
}
120 -
121 -
func isValidSession(db *sql.DB, token string) bool {
122 -
	var expires string
123 -
	err := db.QueryRow(`SELECT expires_at FROM sessions WHERE token = ?`, token).Scan(&expires)
124 -
	if err != nil {
125 -
		return false
126 -
	}
127 -
	t, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.UTC)
128 -
	return err == nil && t.After(time.Now().UTC())
129 -
}
130 -
131 -
func deleteSession(db *sql.DB, token string) {
132 -
	_, _ = db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
133 -
}
134 -
135 -
func pruneExpiredSessions(db *sql.DB) {
136 -
	_, _ = db.Exec(`DELETE FROM sessions WHERE expires_at < datetime('now')`)
137 -
}
apps/jotts-go/go.mod +12 −0
3 3
go 1.24.4
4 4
5 5
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
6 10
	github.com/yuin/goldmark v1.7.8
7 11
	modernc.org/sqlite v1.37.1
8 12
)
9 13
14 +
replace (
15 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
16 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
17 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
18 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
19 +
)
20 +
10 21
require (
11 22
	github.com/dustin/go-humanize v1.0.1 // indirect
12 23
	github.com/google/uuid v1.6.0 // indirect
13 24
	github.com/mattn/go-isatty v0.0.20 // indirect
14 25
	github.com/ncruces/go-strftime v0.1.9 // indirect
15 26
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
27 +
	golang.org/x/crypto v0.39.0 // indirect
16 28
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
17 29
	golang.org/x/sys v0.33.0 // indirect
18 30
	modernc.org/libc v1.65.7 // indirect
apps/jotts-go/go.sum +2 −0
12 12
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
13 13
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
14 14
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
15 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
16 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
15 17
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
16 18
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
17 19
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
apps/jotts-go/handlers_api.go +13 −11
3 3
import (
4 4
	"net/http"
5 5
	"strings"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
6 8
)
7 9
8 10
func (a *App) apiListNotes(w http.ResponseWriter, r *http.Request) {
9 11
	notes, err := listNotes(a.DB)
10 12
	if err != nil {
11 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
13 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
12 14
		return
13 15
	}
14 16
	if notes == nil {
15 17
		notes = []Note{}
16 18
	}
17 -
	writeJSON(w, http.StatusOK, notes)
19 +
	web.WriteJSON(w, http.StatusOK, notes)
18 20
}
19 21
20 22
func (a *App) apiGetNote(w http.ResponseWriter, r *http.Request) {
21 23
	shortID := r.PathValue("short_id")
22 24
	note, err := getNoteByShortID(a.DB, shortID)
23 25
	if err != nil {
24 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
26 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
25 27
		return
26 28
	}
27 29
	if note == nil {
28 30
		http.NotFound(w, r)
29 31
		return
30 32
	}
31 -
	writeJSON(w, http.StatusOK, note)
33 +
	web.WriteJSON(w, http.StatusOK, note)
32 34
}
33 35
34 36
func (a *App) apiCreateNote(w http.ResponseWriter, r *http.Request) {
35 37
	var body NoteInput
36 -
	if !decodeJSON(w, r, &body) {
38 +
	if !web.DecodeJSON(w, r, &body) {
37 39
		return
38 40
	}
39 41
	title := strings.TrimSpace(body.Title)
43 45
	}
44 46
	note, err := createNote(a.DB, title, body.Content)
45 47
	if err != nil {
46 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
48 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
47 49
		return
48 50
	}
49 -
	writeJSON(w, http.StatusCreated, note)
51 +
	web.WriteJSON(w, http.StatusCreated, note)
50 52
}
51 53
52 54
func (a *App) apiUpdateNote(w http.ResponseWriter, r *http.Request) {
53 55
	shortID := r.PathValue("short_id")
54 56
	var body NoteInput
55 -
	if !decodeJSON(w, r, &body) {
57 +
	if !web.DecodeJSON(w, r, &body) {
56 58
		return
57 59
	}
58 60
	title := strings.TrimSpace(body.Title)
62 64
	}
63 65
	note, err := updateNoteByShortID(a.DB, shortID, title, body.Content)
64 66
	if err != nil {
65 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
67 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
66 68
		return
67 69
	}
68 70
	if note == nil {
69 71
		http.NotFound(w, r)
70 72
		return
71 73
	}
72 -
	writeJSON(w, http.StatusOK, note)
74 +
	web.WriteJSON(w, http.StatusOK, note)
73 75
}
74 76
75 77
func (a *App) apiDeleteNote(w http.ResponseWriter, r *http.Request) {
76 78
	shortID := r.PathValue("short_id")
77 79
	ok, err := deleteNoteByShortID(a.DB, shortID)
78 80
	if err != nil {
79 -
		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
81 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
80 82
		return
81 83
	}
82 84
	if !ok {
apps/jotts-go/handlers_web.go +23 −27
4 4
	"html/template"
5 5
	"net/http"
6 6
	"strings"
7 -
	"time"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
9 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 10
)
9 11
10 12
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
11 -
	a.render(w, "login.html", loginPageData{Error: r.URL.Query().Get("error")})
13 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
12 14
}
13 15
14 16
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
15 17
	if err := r.ParseForm(); err != nil {
16 -
		http.Redirect(w, r, "/login?error=Invalid+request", http.StatusSeeOther)
18 +
		web.RedirectWithError(w, r, "/login", "Invalid request")
17 19
		return
18 20
	}
19 -
	password := r.FormValue("password")
20 -
	if !secureEqual(password, a.Password) {
21 -
		http.Redirect(w, r, "/login?error=Invalid+password", http.StatusSeeOther)
21 +
	if !auth.SecureEqual(r.FormValue("password"), a.Password) {
22 +
		web.RedirectWithError(w, r, "/login", "Invalid password")
22 23
		return
23 24
	}
24 -
	token, err := generateSessionToken()
25 +
	token, err := a.Sessions.Create()
25 26
	if err != nil {
26 -
		a.Log.Error("session token failed", "err", err)
27 -
		http.Redirect(w, r, "/login?error=Server+error", http.StatusSeeOther)
28 -
		return
29 -
	}
30 -
	if err := createSession(a.DB, token, time.Now().UTC().Add(7*24*time.Hour)); err != nil {
31 27
		a.Log.Error("create session failed", "err", err)
32 -
		http.Redirect(w, r, "/login?error=Server+error", http.StatusSeeOther)
28 +
		web.RedirectWithError(w, r, "/login", "Server error")
33 29
		return
34 30
	}
35 -
	http.SetCookie(w, a.sessionCookie(token))
31 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
36 32
	http.Redirect(w, r, "/", http.StatusSeeOther)
37 33
}
38 34
39 35
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
40 -
	if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
41 -
		deleteSession(a.DB, c.Value)
36 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
37 +
		a.Sessions.Delete(c.Value)
42 38
	}
43 -
	http.SetCookie(w, a.clearSessionCookie())
39 +
	http.SetCookie(w, a.Sessions.ClearCookie())
44 40
	http.Redirect(w, r, "/login", http.StatusSeeOther)
45 41
}
46 42
51 47
		http.Error(w, "internal server error", http.StatusInternalServerError)
52 48
		return
53 49
	}
54 -
	a.render(w, "index.html", indexPageData{Notes: notes})
50 +
	web.Render(a.Templates, w, "index.html", indexPageData{Notes: notes}, a.Log)
55 51
}
56 52
57 53
func (a *App) newNoteGetHandler(w http.ResponseWriter, r *http.Request) {
58 -
	a.render(w, "new.html", newPageData{Error: r.URL.Query().Get("error")})
54 +
	web.Render(a.Templates, w, "new.html", newPageData{Error: r.URL.Query().Get("error")}, a.Log)
59 55
}
60 56
61 57
func (a *App) createNoteHandler(w http.ResponseWriter, r *http.Request) {
62 58
	if err := r.ParseForm(); err != nil {
63 -
		redirectWithError(w, r, "/notes/new", "Invalid request")
59 +
		web.RedirectWithError(w, r, "/notes/new", "Invalid request")
64 60
		return
65 61
	}
66 62
	title := strings.TrimSpace(r.FormValue("title"))
67 63
	content := r.FormValue("content")
68 64
	if title == "" {
69 -
		redirectWithError(w, r, "/notes/new", "Title is required")
65 +
		web.RedirectWithError(w, r, "/notes/new", "Title is required")
70 66
		return
71 67
	}
72 68
	note, err := createNote(a.DB, title, content)
73 69
	if err != nil {
74 70
		a.Log.Error("create note failed", "err", err)
75 -
		redirectWithError(w, r, "/notes/new", "Failed to create note")
71 +
		web.RedirectWithError(w, r, "/notes/new", "Failed to create note")
76 72
		return
77 73
	}
78 74
	http.Redirect(w, r, "/notes/"+note.ShortID, http.StatusSeeOther)
96 92
		http.Error(w, "internal server error", http.StatusInternalServerError)
97 93
		return
98 94
	}
99 -
	a.render(w, "view.html", viewPageData{Note: *note, Rendered: template.HTML(rendered)})
95 +
	web.Render(a.Templates, w, "view.html", viewPageData{Note: *note, Rendered: template.HTML(rendered)}, a.Log)
100 96
}
101 97
102 98
func (a *App) editNoteGetHandler(w http.ResponseWriter, r *http.Request) {
111 107
		http.Error(w, "Note not found", http.StatusNotFound)
112 108
		return
113 109
	}
114 -
	a.render(w, "edit.html", editPageData{Note: *note, Error: r.URL.Query().Get("error")})
110 +
	web.Render(a.Templates, w, "edit.html", editPageData{Note: *note, Error: r.URL.Query().Get("error")}, a.Log)
115 111
}
116 112
117 113
func (a *App) updateNoteHandler(w http.ResponseWriter, r *http.Request) {
118 114
	shortID := r.PathValue("short_id")
119 115
	if err := r.ParseForm(); err != nil {
120 -
		redirectWithError(w, r, "/notes/"+shortID+"/edit", "Invalid request")
116 +
		web.RedirectWithError(w, r, "/notes/"+shortID+"/edit", "Invalid request")
121 117
		return
122 118
	}
123 119
	title := strings.TrimSpace(r.FormValue("title"))
124 120
	content := r.FormValue("content")
125 121
	if title == "" {
126 -
		redirectWithError(w, r, "/notes/"+shortID+"/edit", "Title is required")
122 +
		web.RedirectWithError(w, r, "/notes/"+shortID+"/edit", "Title is required")
127 123
		return
128 124
	}
129 125
	note, err := updateNoteByShortID(a.DB, shortID, title, content)
130 126
	if err != nil {
131 127
		a.Log.Error("update note failed", "err", err)
132 -
		redirectWithError(w, r, "/notes/"+shortID+"/edit", "Failed to update note")
128 +
		web.RedirectWithError(w, r, "/notes/"+shortID+"/edit", "Failed to update note")
133 129
		return
134 130
	}
135 131
	if note == nil {
apps/jotts-go/main.go +14 −6
6 6
	"log/slog"
7 7
	"net/http"
8 8
	"os"
9 -
	"strings"
9 +
10 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
11 +
	"github.com/stevedylandev/andromeda/crates-go/config"
10 12
)
11 13
12 14
func main() {
13 -
	loadDotEnv(".env")
15 +
	config.LoadDotEnv(".env")
14 16
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
15 17
16 -
	dbPath := getenv("JOTTS_DB_PATH", "jotts.sqlite")
18 +
	dbPath := config.Getenv("JOTTS_DB_PATH", "jotts.sqlite")
17 19
	db, err := openDB(dbPath)
18 20
	if err != nil {
19 21
		log.Fatal(err)
20 22
	}
21 23
	defer db.Close()
22 -
	pruneExpiredSessions(db)
24 +
25 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
26 +
	if err := sessions.EnsureSchema(); err != nil {
27 +
		log.Fatal(err)
28 +
	}
29 +
	sessions.PruneExpired()
23 30
24 31
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
25 32
37 44
		DB:           db,
38 45
		Log:          logger,
39 46
		Templates:    tmpl,
47 +
		Sessions:     sessions,
40 48
		Password:     password,
41 49
		APIKey:       apiKey,
42 -
		CookieSecure: strings.EqualFold(os.Getenv("COOKIE_SECURE"), "true"),
50 +
		CookieSecure: sessions.CookieSecure,
43 51
	}
44 52
45 -
	addr := getenv("HOST", "127.0.0.1") + ":" + getenv("PORT", "3000")
53 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
46 54
	logger.Info("jotts-go server running", "addr", addr)
47 55
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
48 56
		log.Fatal(err)
apps/jotts-go/middleware.go (deleted) +0 −69
1 -
package main
2 -
3 -
import (
4 -
	"crypto/subtle"
5 -
	"net/http"
6 -
)
7 -
8 -
const sessionCookieName = "session"
9 -
10 -
func (a *App) requireSession(next http.HandlerFunc) http.HandlerFunc {
11 -
	return func(w http.ResponseWriter, r *http.Request) {
12 -
		if !a.hasValidSession(r) {
13 -
			http.Redirect(w, r, "/login", http.StatusSeeOther)
14 -
			return
15 -
		}
16 -
		next(w, r)
17 -
	}
18 -
}
19 -
20 -
func (a *App) requireAPIKey(next http.HandlerFunc) http.HandlerFunc {
21 -
	return func(w http.ResponseWriter, r *http.Request) {
22 -
		if a.APIKey == "" {
23 -
			http.Error(w, "API key not configured on server", http.StatusForbidden)
24 -
			return
25 -
		}
26 -
		provided := r.Header.Get("x-api-key")
27 -
		if !secureEqual(provided, a.APIKey) {
28 -
			http.Error(w, "Invalid API key", http.StatusUnauthorized)
29 -
			return
30 -
		}
31 -
		next(w, r)
32 -
	}
33 -
}
34 -
35 -
func (a *App) hasValidSession(r *http.Request) bool {
36 -
	c, err := r.Cookie(sessionCookieName)
37 -
	if err != nil || c.Value == "" {
38 -
		return false
39 -
	}
40 -
	return isValidSession(a.DB, c.Value)
41 -
}
42 -
43 -
func (a *App) sessionCookie(token string) *http.Cookie {
44 -
	return &http.Cookie{
45 -
		Name:     sessionCookieName,
46 -
		Value:    token,
47 -
		Path:     "/",
48 -
		HttpOnly: true,
49 -
		Secure:   a.CookieSecure,
50 -
		SameSite: http.SameSiteLaxMode,
51 -
		MaxAge:   7 * 24 * 60 * 60,
52 -
	}
53 -
}
54 -
55 -
func (a *App) clearSessionCookie() *http.Cookie {
56 -
	return &http.Cookie{
57 -
		Name:     sessionCookieName,
58 -
		Value:    "",
59 -
		Path:     "/",
60 -
		HttpOnly: true,
61 -
		Secure:   a.CookieSecure,
62 -
		SameSite: http.SameSiteLaxMode,
63 -
		MaxAge:   -1,
64 -
	}
65 -
}
66 -
67 -
func secureEqual(a, b string) bool {
68 -
	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
69 -
}
apps/jotts-go/routes.go +28 −15
1 1
package main
2 2
3 -
import "net/http"
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
7 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
4 10
5 11
func (a *App) routes() *http.ServeMux {
6 12
	mux := http.NewServeMux()
7 13
8 -
	mux.HandleFunc("GET /static/", a.embeddedHandler("static"))
9 -
	mux.HandleFunc("GET /assets/", a.embeddedHandler("assets"))
14 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
15 +
	darkmatter.Mount(mux, "/assets")
16 +
17 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
18 +
		return a.Sessions.RequireSession("/login", next)
19 +
	}
20 +
	requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
21 +
		return auth.RequireAPIKey(a.APIKey, next)
22 +
	}
10 23
11 24
	mux.HandleFunc("GET /login", a.loginGetHandler)
12 25
	mux.HandleFunc("POST /login", a.loginPostHandler)
13 26
	mux.HandleFunc("GET /logout", a.logoutHandler)
14 27
15 -
	mux.HandleFunc("GET /{$}", a.requireSession(a.indexHandler))
16 -
	mux.HandleFunc("GET /notes/new", a.requireSession(a.newNoteGetHandler))
17 -
	mux.HandleFunc("POST /notes", a.requireSession(a.createNoteHandler))
18 -
	mux.HandleFunc("GET /notes/{short_id}", a.requireSession(a.viewNoteHandler))
19 -
	mux.HandleFunc("GET /notes/{short_id}/edit", a.requireSession(a.editNoteGetHandler))
20 -
	mux.HandleFunc("POST /notes/{short_id}", a.requireSession(a.updateNoteHandler))
21 -
	mux.HandleFunc("POST /notes/{short_id}/delete", a.requireSession(a.deleteNoteHandler))
28 +
	mux.HandleFunc("GET /{$}", requireSession(a.indexHandler))
29 +
	mux.HandleFunc("GET /notes/new", requireSession(a.newNoteGetHandler))
30 +
	mux.HandleFunc("POST /notes", requireSession(a.createNoteHandler))
31 +
	mux.HandleFunc("GET /notes/{short_id}", requireSession(a.viewNoteHandler))
32 +
	mux.HandleFunc("GET /notes/{short_id}/edit", requireSession(a.editNoteGetHandler))
33 +
	mux.HandleFunc("POST /notes/{short_id}", requireSession(a.updateNoteHandler))
34 +
	mux.HandleFunc("POST /notes/{short_id}/delete", requireSession(a.deleteNoteHandler))
22 35
23 -
	mux.HandleFunc("GET /api/notes", a.requireAPIKey(a.apiListNotes))
24 -
	mux.HandleFunc("POST /api/notes", a.requireAPIKey(a.apiCreateNote))
25 -
	mux.HandleFunc("GET /api/notes/{short_id}", a.requireAPIKey(a.apiGetNote))
26 -
	mux.HandleFunc("PUT /api/notes/{short_id}", a.requireAPIKey(a.apiUpdateNote))
27 -
	mux.HandleFunc("DELETE /api/notes/{short_id}", a.requireAPIKey(a.apiDeleteNote))
36 +
	mux.HandleFunc("GET /api/notes", requireAPIKey(a.apiListNotes))
37 +
	mux.HandleFunc("POST /api/notes", requireAPIKey(a.apiCreateNote))
38 +
	mux.HandleFunc("GET /api/notes/{short_id}", requireAPIKey(a.apiGetNote))
39 +
	mux.HandleFunc("PUT /api/notes/{short_id}", requireAPIKey(a.apiUpdateNote))
40 +
	mux.HandleFunc("DELETE /api/notes/{short_id}", requireAPIKey(a.apiDeleteNote))
28 41
29 42
	return mux
30 43
}
apps/jotts-go/static/fonts/CommitMono-400-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/static/fonts/CommitMono-700-Regular.otf (deleted) +0 −0

Binary file — no preview.

apps/jotts-go/util.go (deleted) +0 −62
1 -
package main
2 -
3 -
import (
4 -
	"crypto/rand"
5 -
	"encoding/hex"
6 -
	"net/http"
7 -
	"net/url"
8 -
	"os"
9 -
	"strings"
10 -
)
11 -
12 -
const shortIDAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
13 -
14 -
func getenv(key, fallback string) string {
15 -
	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
16 -
		return v
17 -
	}
18 -
	return fallback
19 -
}
20 -
21 -
func loadDotEnv(path string) {
22 -
	data, err := os.ReadFile(path)
23 -
	if err != nil {
24 -
		return
25 -
	}
26 -
	for _, line := range strings.Split(string(data), "\n") {
27 -
		line = strings.TrimSpace(line)
28 -
		if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
29 -
			continue
30 -
		}
31 -
		parts := strings.SplitN(line, "=", 2)
32 -
		key := strings.TrimSpace(parts[0])
33 -
		val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
34 -
		if os.Getenv(key) == "" {
35 -
			_ = os.Setenv(key, val)
36 -
		}
37 -
	}
38 -
}
39 -
40 -
func generateShortID(n int) (string, error) {
41 -
	buf := make([]byte, n)
42 -
	if _, err := rand.Read(buf); err != nil {
43 -
		return "", err
44 -
	}
45 -
	out := make([]byte, n)
46 -
	for i, b := range buf {
47 -
		out[i] = shortIDAlphabet[int(b)%len(shortIDAlphabet)]
48 -
	}
49 -
	return string(out), nil
50 -
}
51 -
52 -
func generateSessionToken() (string, error) {
53 -
	buf := make([]byte, 32)
54 -
	if _, err := rand.Read(buf); err != nil {
55 -
		return "", err
56 -
	}
57 -
	return hex.EncodeToString(buf), nil
58 -
}
59 -
60 -
func redirectWithError(w http.ResponseWriter, r *http.Request, target, msg string) {
61 -
	http.Redirect(w, r, target+"?error="+url.QueryEscape(msg), http.StatusSeeOther)
62 -
}
apps/jotts-go/web.go (deleted) +0 −48
1 -
package main
2 -
3 -
import (
4 -
	"encoding/json"
5 -
	"mime"
6 -
	"net/http"
7 -
	"path/filepath"
8 -
	"strings"
9 -
)
10 -
11 -
func (a *App) embeddedHandler(prefix string) http.HandlerFunc {
12 -
	return func(w http.ResponseWriter, r *http.Request) {
13 -
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
14 -
		path := filepath.ToSlash(filepath.Join(prefix, name))
15 -
		data, err := appFS.ReadFile(path)
16 -
		if err != nil {
17 -
			http.NotFound(w, r)
18 -
			return
19 -
		}
20 -
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
21 -
			w.Header().Set("Content-Type", ct)
22 -
		}
23 -
		_, _ = w.Write(data)
24 -
	}
25 -
}
26 -
27 -
func (a *App) render(w http.ResponseWriter, name string, data any) {
28 -
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
29 -
	if err := a.Templates.ExecuteTemplate(w, name, data); err != nil {
30 -
		a.Log.Error("template render failed", "name", name, "err", err)
31 -
		http.Error(w, "template error", http.StatusInternalServerError)
32 -
	}
33 -
}
34 -
35 -
func writeJSON(w http.ResponseWriter, status int, data any) {
36 -
	w.Header().Set("Content-Type", "application/json")
37 -
	w.WriteHeader(status)
38 -
	_ = json.NewEncoder(w).Encode(data)
39 -
}
40 -
41 -
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
42 -
	defer r.Body.Close()
43 -
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
44 -
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON"})
45 -
		return false
46 -
	}
47 -
	return true
48 -
}
apps/library-go/.env.example (added) +8 −0
1 +
ADMIN_PASSWORD=changeme
2 +
COOKIE_SECURE=false
3 +
BASE_URL=http://localhost:3000
4 +
HOST=127.0.0.1
5 +
PORT=3000
6 +
LIBRARY_DB_PATH=library.sqlite
7 +
GOOGLE_BOOKS_API_KEY=
8 +
LIBRARY_DISPLAY_MODE=inline
apps/library-go/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t library-go -f apps/library-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/library-go/go.mod apps/library-go/go.sum ./apps/library-go/
6 +
WORKDIR /app/apps/library-go
7 +
RUN go mod download
8 +
COPY apps/library-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /library-go .
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 /library-go /usr/local/bin/library-go
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
EXPOSE 3000
18 +
CMD ["library-go"]
apps/library-go/README.md (added) +24 −0
1 +
# library-go
2 +
3 +
Go rewrite of [library](../library). Personal book tracker with Google Books
4 +
search.
5 +
6 +
## Quickstart
7 +
8 +
```bash
9 +
cp .env.example .env
10 +
go run .
11 +
```
12 +
13 +
### Environment Variables
14 +
15 +
| Variable | Default | Description |
16 +
|---|---|---|
17 +
| `ADMIN_PASSWORD` | `changeme` | Admin login password |
18 +
| `LIBRARY_DB_PATH` | `library.sqlite` | SQLite path |
19 +
| `GOOGLE_BOOKS_API_KEY` | — | Optional Google Books API key |
20 +
| `BASE_URL` | `http://localhost:3000` | Public base URL (OG tags) |
21 +
| `HOST` | `0.0.0.0` | Bind host |
22 +
| `PORT` | `3000` | Server port |
23 +
| `COOKIE_SECURE` | `false` | Mark session cookie Secure |
24 +
| `LIBRARY_DISPLAY_MODE` | `inline` | `inline` or `nav` |
apps/library-go/app.go (added) +88 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
10 +
)
11 +
12 +
//go:embed templates/*.html static/*
13 +
var appFS embed.FS
14 +
15 +
type DisplayMode int
16 +
17 +
const (
18 +
	DisplayModeInline DisplayMode = iota
19 +
	DisplayModeNav
20 +
)
21 +
22 +
func parseDisplayMode(s string) DisplayMode {
23 +
	if s == "nav" {
24 +
		return DisplayModeNav
25 +
	}
26 +
	return DisplayModeInline
27 +
}
28 +
29 +
type App struct {
30 +
	DB             *sql.DB
31 +
	Log            *slog.Logger
32 +
	Templates      *template.Template
33 +
	Sessions       *auth.Store
34 +
	AdminPassword  string
35 +
	GoogleBooksKey string
36 +
	CookieSecure   bool
37 +
	BaseURL        string
38 +
	DisplayMode    DisplayMode
39 +
}
40 +
41 +
type bookView struct {
42 +
	Title    string
43 +
	Authors  string
44 +
	CoverURL string
45 +
	Notes    string
46 +
}
47 +
48 +
type sectionView struct {
49 +
	Label string
50 +
	Books []bookView
51 +
}
52 +
53 +
type navCategory struct {
54 +
	Slug   string
55 +
	Label  string
56 +
	Active bool
57 +
}
58 +
59 +
type indexPageData struct {
60 +
	BaseURL       string
61 +
	Sections      []sectionView
62 +
	NavMode       bool
63 +
	NavCategories []navCategory
64 +
}
65 +
66 +
type loginPageData struct {
67 +
	Error string
68 +
}
69 +
70 +
type adminBookRow struct {
71 +
	ID       int64
72 +
	Title    string
73 +
	Authors  string
74 +
	ISBN     string
75 +
	CoverURL string
76 +
	Notes    string
77 +
	Status   string
78 +
}
79 +
80 +
type adminPageData struct {
81 +
	Success         string
82 +
	Error           string
83 +
	Books           []adminBookRow
84 +
	Labels          CategoryLabels
85 +
	LibraryQuery    string
86 +
	LibraryResults  []adminBookRow
87 +
	LibrarySearched bool
88 +
}
apps/library-go/db.go (added) +260 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	_ "modernc.org/sqlite"
10 +
)
11 +
12 +
const booksSchema = `
13 +
CREATE TABLE IF NOT EXISTS books (
14 +
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
15 +
    google_id     TEXT UNIQUE,
16 +
    title         TEXT NOT NULL,
17 +
    authors       TEXT NOT NULL,
18 +
    isbn          TEXT,
19 +
    cover_url     TEXT,
20 +
    notes         TEXT,
21 +
    status        TEXT NOT NULL CHECK (status IN ('read','reading','want')),
22 +
    added_at      INTEGER NOT NULL,
23 +
    updated_at    INTEGER NOT NULL
24 +
);
25 +
CREATE INDEX IF NOT EXISTS idx_books_status_added ON books(status, added_at DESC);
26 +
27 +
CREATE TABLE IF NOT EXISTS settings (
28 +
    key   TEXT PRIMARY KEY,
29 +
    value TEXT NOT NULL
30 +
);
31 +
`
32 +
33 +
type Book struct {
34 +
	ID        int64   `json:"id"`
35 +
	GoogleID  *string `json:"google_id,omitempty"`
36 +
	Title     string  `json:"title"`
37 +
	Authors   string  `json:"authors"`
38 +
	ISBN      *string `json:"isbn,omitempty"`
39 +
	CoverURL  *string `json:"cover_url,omitempty"`
40 +
	Notes     *string `json:"notes,omitempty"`
41 +
	Status    string  `json:"status"`
42 +
	AddedAt   int64   `json:"added_at"`
43 +
	UpdatedAt int64   `json:"updated_at"`
44 +
}
45 +
46 +
type NewBook struct {
47 +
	GoogleID *string
48 +
	Title    string
49 +
	Authors  string
50 +
	ISBN     *string
51 +
	CoverURL *string
52 +
	Notes    *string
53 +
	Status   string
54 +
}
55 +
56 +
type CategoryLabels struct {
57 +
	Reading string
58 +
	Read    string
59 +
	Want    string
60 +
}
61 +
62 +
func defaultLabels() CategoryLabels {
63 +
	return CategoryLabels{Reading: "Reading", Read: "Read", Want: "Want to Read"}
64 +
}
65 +
66 +
func openDB(path string) (*sql.DB, error) {
67 +
	db, err := sql.Open("sqlite", path)
68 +
	if err != nil {
69 +
		return nil, err
70 +
	}
71 +
	db.SetMaxOpenConns(1)
72 +
	db.SetMaxIdleConns(1)
73 +
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
74 +
		return nil, err
75 +
	}
76 +
	if _, err := db.Exec(booksSchema); err != nil {
77 +
		return nil, err
78 +
	}
79 +
	return db, nil
80 +
}
81 +
82 +
const selectCols = `id, google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at`
83 +
84 +
func scanBook(s interface{ Scan(...any) error }) (*Book, error) {
85 +
	var b Book
86 +
	var gid, isbn, cover, notes sql.NullString
87 +
	err := s.Scan(&b.ID, &gid, &b.Title, &b.Authors, &isbn, &cover, &notes, &b.Status, &b.AddedAt, &b.UpdatedAt)
88 +
	if errors.Is(err, sql.ErrNoRows) {
89 +
		return nil, nil
90 +
	}
91 +
	if err != nil {
92 +
		return nil, err
93 +
	}
94 +
	if gid.Valid {
95 +
		v := gid.String
96 +
		b.GoogleID = &v
97 +
	}
98 +
	if isbn.Valid {
99 +
		v := isbn.String
100 +
		b.ISBN = &v
101 +
	}
102 +
	if cover.Valid {
103 +
		v := cover.String
104 +
		b.CoverURL = &v
105 +
	}
106 +
	if notes.Valid {
107 +
		v := notes.String
108 +
		b.Notes = &v
109 +
	}
110 +
	return &b, nil
111 +
}
112 +
113 +
func listBooks(db *sql.DB, status string) ([]Book, error) {
114 +
	var rows *sql.Rows
115 +
	var err error
116 +
	if status != "" {
117 +
		rows, err = db.Query(`SELECT `+selectCols+` FROM books WHERE status = ? ORDER BY added_at DESC`, status)
118 +
	} else {
119 +
		rows, err = db.Query(`SELECT ` + selectCols + ` FROM books ORDER BY added_at DESC`)
120 +
	}
121 +
	if err != nil {
122 +
		return nil, err
123 +
	}
124 +
	defer rows.Close()
125 +
	var out []Book
126 +
	for rows.Next() {
127 +
		b, err := scanBook(rows)
128 +
		if err != nil {
129 +
			return nil, err
130 +
		}
131 +
		out = append(out, *b)
132 +
	}
133 +
	return out, rows.Err()
134 +
}
135 +
136 +
func getBook(db *sql.DB, id int64) (*Book, error) {
137 +
	return scanBook(db.QueryRow(`SELECT `+selectCols+` FROM books WHERE id = ?`, id))
138 +
}
139 +
140 +
func insertBook(db *sql.DB, b NewBook) (int64, error) {
141 +
	now := time.Now().UTC().Unix()
142 +
	var gid, isbn, cover, notes any
143 +
	if b.GoogleID != nil && *b.GoogleID != "" {
144 +
		gid = *b.GoogleID
145 +
	}
146 +
	if b.ISBN != nil && *b.ISBN != "" {
147 +
		isbn = *b.ISBN
148 +
	}
149 +
	if b.CoverURL != nil && *b.CoverURL != "" {
150 +
		cover = *b.CoverURL
151 +
	}
152 +
	if b.Notes != nil && *b.Notes != "" {
153 +
		notes = *b.Notes
154 +
	}
155 +
	res, err := db.Exec(
156 +
		`INSERT INTO books (google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at)
157 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
158 +
		 ON CONFLICT(google_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
159 +
		gid, b.Title, b.Authors, isbn, cover, notes, b.Status, now, now,
160 +
	)
161 +
	if err != nil {
162 +
		return 0, err
163 +
	}
164 +
	return res.LastInsertId()
165 +
}
166 +
167 +
func updateBookStatus(db *sql.DB, id int64, status string) error {
168 +
	_, err := db.Exec(`UPDATE books SET status = ?, updated_at = ? WHERE id = ?`, status, time.Now().UTC().Unix(), id)
169 +
	return err
170 +
}
171 +
172 +
func updateBookNotes(db *sql.DB, id int64, notes *string) error {
173 +
	var v any
174 +
	if notes != nil && *notes != "" {
175 +
		v = *notes
176 +
	}
177 +
	_, err := db.Exec(`UPDATE books SET notes = ?, updated_at = ? WHERE id = ?`, v, time.Now().UTC().Unix(), id)
178 +
	return err
179 +
}
180 +
181 +
func deleteBook(db *sql.DB, id int64) error {
182 +
	_, err := db.Exec(`DELETE FROM books WHERE id = ?`, id)
183 +
	return err
184 +
}
185 +
186 +
func searchBooks(db *sql.DB, q string) ([]Book, error) {
187 +
	term := strings.TrimSpace(q)
188 +
	if term == "" {
189 +
		return nil, nil
190 +
	}
191 +
	pattern := "%" + strings.ToLower(term) + "%"
192 +
	rows, err := db.Query(
193 +
		`SELECT `+selectCols+` FROM books
194 +
		 WHERE LOWER(title) LIKE ? OR LOWER(authors) LIKE ? OR LOWER(IFNULL(isbn,'')) LIKE ?
195 +
		 ORDER BY added_at DESC LIMIT 50`,
196 +
		pattern, pattern, pattern,
197 +
	)
198 +
	if err != nil {
199 +
		return nil, err
200 +
	}
201 +
	defer rows.Close()
202 +
	var out []Book
203 +
	for rows.Next() {
204 +
		b, err := scanBook(rows)
205 +
		if err != nil {
206 +
			return nil, err
207 +
		}
208 +
		out = append(out, *b)
209 +
	}
210 +
	return out, rows.Err()
211 +
}
212 +
213 +
func getSetting(db *sql.DB, key string) (string, bool, error) {
214 +
	var v string
215 +
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
216 +
	if errors.Is(err, sql.ErrNoRows) {
217 +
		return "", false, nil
218 +
	}
219 +
	if err != nil {
220 +
		return "", false, err
221 +
	}
222 +
	return v, true, nil
223 +
}
224 +
225 +
func setSetting(db *sql.DB, key, value string) error {
226 +
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
227 +
	return err
228 +
}
229 +
230 +
func getCategoryLabels(db *sql.DB) (CategoryLabels, error) {
231 +
	labels := defaultLabels()
232 +
	if v, ok, err := getSetting(db, "category_label.reading"); err == nil && ok {
233 +
		labels.Reading = v
234 +
	} else if err != nil {
235 +
		return labels, err
236 +
	}
237 +
	if v, ok, err := getSetting(db, "category_label.read"); err == nil && ok {
238 +
		labels.Read = v
239 +
	}
240 +
	if v, ok, err := getSetting(db, "category_label.want"); err == nil && ok {
241 +
		labels.Want = v
242 +
	}
243 +
	return labels, nil
244 +
}
245 +
246 +
func labelFor(l CategoryLabels, status string) string {
247 +
	switch status {
248 +
	case "reading":
249 +
		return l.Reading
250 +
	case "read":
251 +
		return l.Read
252 +
	case "want":
253 +
		return l.Want
254 +
	}
255 +
	return status
256 +
}
257 +
258 +
func validStatus(s string) bool {
259 +
	return s == "read" || s == "reading" || s == "want"
260 +
}
apps/library-go/docker-compose.yml (added) +22 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/library-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - LIBRARY_DB_PATH=/data/library-go.sqlite
12 +
      - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
13 +
      - GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY:-}
14 +
      - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}}
15 +
      - LIBRARY_DISPLAY_MODE=${LIBRARY_DISPLAY_MODE:-inline}
16 +
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
17 +
    volumes:
18 +
      - library-go-data:/data
19 +
    restart: unless-stopped
20 +
21 +
volumes:
22 +
  library-go-data:
apps/library-go/go.mod (added) +32 −0
1 +
module github.com/stevedylandev/andromeda/apps/library-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 +
	modernc.org/sqlite v1.37.1
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 +
)
26 +
27 +
replace (
28 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
29 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
30 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
31 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
32 +
)
apps/library-go/go.sum (added) +49 −0
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=
apps/library-go/google_books.go (added) +136 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"encoding/json"
6 +
	"fmt"
7 +
	"net/http"
8 +
	"net/url"
9 +
	"strings"
10 +
	"time"
11 +
)
12 +
13 +
type SearchHit struct {
14 +
	GoogleID string  `json:"google_id"`
15 +
	Title    string  `json:"title"`
16 +
	Authors  string  `json:"authors"`
17 +
	ISBN     *string `json:"isbn,omitempty"`
18 +
	CoverURL *string `json:"cover_url,omitempty"`
19 +
}
20 +
21 +
type volumesResponse struct {
22 +
	Items []volume `json:"items"`
23 +
}
24 +
25 +
type volume struct {
26 +
	ID         string     `json:"id"`
27 +
	VolumeInfo volumeInfo `json:"volumeInfo"`
28 +
}
29 +
30 +
type volumeInfo struct {
31 +
	Title       string       `json:"title"`
32 +
	Authors     []string     `json:"authors"`
33 +
	Identifiers []identifier `json:"industryIdentifiers"`
34 +
	ImageLinks  *imageLinks  `json:"imageLinks"`
35 +
}
36 +
37 +
type identifier struct {
38 +
	Kind       string `json:"type"`
39 +
	Identifier string `json:"identifier"`
40 +
}
41 +
42 +
type imageLinks struct {
43 +
	Thumbnail      string `json:"thumbnail"`
44 +
	SmallThumbnail string `json:"smallThumbnail"`
45 +
}
46 +
47 +
func googleBooksSearch(ctx context.Context, query, apiKey string) ([]SearchHit, error) {
48 +
	trimmed := strings.TrimSpace(query)
49 +
	if trimmed == "" {
50 +
		return nil, nil
51 +
	}
52 +
	normalized := strings.Map(func(r rune) rune {
53 +
		if r == ' ' || r == '\t' || r == '\n' || r == '-' {
54 +
			return -1
55 +
		}
56 +
		return r
57 +
	}, trimmed)
58 +
	isISBN := (len(normalized) == 10 || len(normalized) == 13) && isISBNChars(normalized)
59 +
	q := trimmed
60 +
	if isISBN {
61 +
		q = "isbn:" + strings.ToUpper(normalized)
62 +
	}
63 +
	u := "https://www.googleapis.com/books/v1/volumes?q=" + url.QueryEscape(q) + "&maxResults=10&printType=books"
64 +
	if apiKey != "" {
65 +
		u += "&key=" + url.QueryEscape(apiKey)
66 +
	}
67 +
68 +
	client := &http.Client{Timeout: 8 * time.Second}
69 +
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
70 +
	if err != nil {
71 +
		return nil, err
72 +
	}
73 +
	req.Header.Set("User-Agent", "andromeda-library-go/0.1")
74 +
	resp, err := client.Do(req)
75 +
	if err != nil {
76 +
		return nil, fmt.Errorf("request: %w", err)
77 +
	}
78 +
	defer resp.Body.Close()
79 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
80 +
		return nil, fmt.Errorf("google books status %s", resp.Status)
81 +
	}
82 +
	var data volumesResponse
83 +
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
84 +
		return nil, fmt.Errorf("parse: %w", err)
85 +
	}
86 +
	hits := make([]SearchHit, 0, len(data.Items))
87 +
	for _, v := range data.Items {
88 +
		title := v.VolumeInfo.Title
89 +
		if title == "" {
90 +
			title = "Untitled"
91 +
		}
92 +
		hit := SearchHit{
93 +
			GoogleID: v.ID,
94 +
			Title:    title,
95 +
			Authors:  strings.Join(v.VolumeInfo.Authors, ", "),
96 +
		}
97 +
		if isbn := pickISBN(v.VolumeInfo.Identifiers); isbn != "" {
98 +
			hit.ISBN = &isbn
99 +
		}
100 +
		if v.VolumeInfo.ImageLinks != nil {
101 +
			cover := v.VolumeInfo.ImageLinks.Thumbnail
102 +
			if cover == "" {
103 +
				cover = v.VolumeInfo.ImageLinks.SmallThumbnail
104 +
			}
105 +
			if cover != "" {
106 +
				cover = strings.Replace(cover, "http://", "https://", 1)
107 +
				hit.CoverURL = &cover
108 +
			}
109 +
		}
110 +
		hits = append(hits, hit)
111 +
	}
112 +
	return hits, nil
113 +
}
114 +
115 +
func pickISBN(ids []identifier) string {
116 +
	for _, i := range ids {
117 +
		if i.Kind == "ISBN_13" {
118 +
			return i.Identifier
119 +
		}
120 +
	}
121 +
	for _, i := range ids {
122 +
		if i.Kind == "ISBN_10" {
123 +
			return i.Identifier
124 +
		}
125 +
	}
126 +
	return ""
127 +
}
128 +
129 +
func isISBNChars(s string) bool {
130 +
	for _, c := range s {
131 +
		if (c < '0' || c > '9') && c != 'X' && c != 'x' {
132 +
			return false
133 +
		}
134 +
	}
135 +
	return true
136 +
}
apps/library-go/handlers_api.go (added) +50 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strconv"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
func (a *App) apiListBooks(w http.ResponseWriter, r *http.Request) {
11 +
	status := r.URL.Query().Get("status")
12 +
	switch status {
13 +
	case "", "all":
14 +
		status = ""
15 +
	default:
16 +
		if !validStatus(status) {
17 +
			web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid status"})
18 +
			return
19 +
		}
20 +
	}
21 +
	books, err := listBooks(a.DB, status)
22 +
	if err != nil {
23 +
		a.Log.Error("list books", "err", err)
24 +
		w.WriteHeader(http.StatusInternalServerError)
25 +
		return
26 +
	}
27 +
	if books == nil {
28 +
		books = []Book{}
29 +
	}
30 +
	web.WriteJSON(w, http.StatusOK, books)
31 +
}
32 +
33 +
func (a *App) apiGetBook(w http.ResponseWriter, r *http.Request) {
34 +
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
35 +
	if err != nil {
36 +
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid id"})
37 +
		return
38 +
	}
39 +
	b, err := getBook(a.DB, id)
40 +
	if err != nil {
41 +
		a.Log.Error("get book", "err", err)
42 +
		w.WriteHeader(http.StatusInternalServerError)
43 +
		return
44 +
	}
45 +
	if b == nil {
46 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
47 +
		return
48 +
	}
49 +
	web.WriteJSON(w, http.StatusOK, b)
50 +
}
apps/library-go/handlers_web.go (added) +263 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strconv"
6 +
	"strings"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
9 +
	"github.com/stevedylandev/andromeda/crates-go/web"
10 +
)
11 +
12 +
var sectionDefs = []struct {
13 +
	Slug   string
14 +
	Status string
15 +
}{
16 +
	{"reading", "reading"},
17 +
	{"read", "read"},
18 +
	{"want", "want"},
19 +
}
20 +
21 +
func bookToView(b Book) bookView {
22 +
	v := bookView{Title: b.Title, Authors: b.Authors}
23 +
	if b.CoverURL != nil {
24 +
		v.CoverURL = *b.CoverURL
25 +
	}
26 +
	if b.Notes != nil {
27 +
		v.Notes = *b.Notes
28 +
	}
29 +
	return v
30 +
}
31 +
32 +
func bookToRow(b Book) adminBookRow {
33 +
	r := adminBookRow{ID: b.ID, Title: b.Title, Authors: b.Authors, Status: b.Status}
34 +
	if b.ISBN != nil {
35 +
		r.ISBN = *b.ISBN
36 +
	}
37 +
	if b.CoverURL != nil {
38 +
		r.CoverURL = *b.CoverURL
39 +
	}
40 +
	if b.Notes != nil {
41 +
		r.Notes = *b.Notes
42 +
	}
43 +
	return r
44 +
}
45 +
46 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
47 +
	all, _ := listBooks(a.DB, "")
48 +
	labels, _ := getCategoryLabels(a.DB)
49 +
50 +
	makeSection := func(status string) sectionView {
51 +
		sec := sectionView{Label: labelFor(labels, status)}
52 +
		for _, b := range all {
53 +
			if b.Status == status {
54 +
				sec.Books = append(sec.Books, bookToView(b))
55 +
			}
56 +
		}
57 +
		return sec
58 +
	}
59 +
60 +
	data := indexPageData{BaseURL: a.BaseURL, NavMode: a.DisplayMode == DisplayModeNav}
61 +
	if data.NavMode {
62 +
		selected := sectionDefs[0].Slug
63 +
		if q := r.URL.Query().Get("category"); q != "" {
64 +
			for _, s := range sectionDefs {
65 +
				if s.Slug == q {
66 +
					selected = q
67 +
					break
68 +
				}
69 +
			}
70 +
		}
71 +
		for _, s := range sectionDefs {
72 +
			data.NavCategories = append(data.NavCategories, navCategory{Slug: s.Slug, Label: labelFor(labels, s.Status), Active: s.Slug == selected})
73 +
		}
74 +
		for _, s := range sectionDefs {
75 +
			if s.Slug == selected {
76 +
				data.Sections = []sectionView{makeSection(s.Status)}
77 +
				break
78 +
			}
79 +
		}
80 +
	} else {
81 +
		for _, s := range sectionDefs {
82 +
			sec := makeSection(s.Status)
83 +
			if len(sec.Books) > 0 {
84 +
				data.Sections = append(data.Sections, sec)
85 +
			}
86 +
		}
87 +
	}
88 +
	web.Render(a.Templates, w, "index.html", data, a.Log)
89 +
}
90 +
91 +
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
92 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
93 +
}
94 +
95 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
96 +
	if a.AdminPassword == "" {
97 +
		web.RedirectWithError(w, r, "/admin/login", "No admin password configured")
98 +
		return
99 +
	}
100 +
	if err := r.ParseForm(); err != nil {
101 +
		web.RedirectWithError(w, r, "/admin/login", "Bad request")
102 +
		return
103 +
	}
104 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) {
105 +
		web.RedirectWithError(w, r, "/admin/login", "Invalid password")
106 +
		return
107 +
	}
108 +
	token, err := a.Sessions.Create()
109 +
	if err != nil {
110 +
		a.Log.Error("create session failed", "err", err)
111 +
		web.RedirectWithError(w, r, "/admin/login", "Session error")
112 +
		return
113 +
	}
114 +
	a.Sessions.PruneExpired()
115 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
116 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
117 +
}
118 +
119 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
120 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
121 +
		a.Sessions.Delete(c.Value)
122 +
	}
123 +
	http.SetCookie(w, a.Sessions.ClearCookie())
124 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
125 +
}
126 +
127 +
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
128 +
	all, _ := listBooks(a.DB, "")
129 +
	labels, _ := getCategoryLabels(a.DB)
130 +
	rows := make([]adminBookRow, 0, len(all))
131 +
	for _, b := range all {
132 +
		rows = append(rows, bookToRow(b))
133 +
	}
134 +
135 +
	libraryQuery := r.URL.Query().Get("q")
136 +
	searched := strings.TrimSpace(libraryQuery) != ""
137 +
	var results []adminBookRow
138 +
	if searched {
139 +
		found, _ := searchBooks(a.DB, libraryQuery)
140 +
		results = make([]adminBookRow, 0, len(found))
141 +
		for _, b := range found {
142 +
			results = append(results, bookToRow(b))
143 +
		}
144 +
	}
145 +
146 +
	web.Render(a.Templates, w, "admin.html", adminPageData{
147 +
		Success:         r.URL.Query().Get("success"),
148 +
		Error:           r.URL.Query().Get("error"),
149 +
		Books:           rows,
150 +
		Labels:          labels,
151 +
		LibraryQuery:    libraryQuery,
152 +
		LibraryResults:  results,
153 +
		LibrarySearched: searched,
154 +
	}, a.Log)
155 +
}
156 +
157 +
func (a *App) adminUpdateLabels(w http.ResponseWriter, r *http.Request) {
158 +
	if err := r.ParseForm(); err != nil {
159 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
160 +
		return
161 +
	}
162 +
	reading := strings.TrimSpace(r.FormValue("reading"))
163 +
	read := strings.TrimSpace(r.FormValue("read"))
164 +
	want := strings.TrimSpace(r.FormValue("want"))
165 +
	if reading == "" || read == "" || want == "" {
166 +
		web.RedirectWithError(w, r, "/admin", "Labels cannot be empty")
167 +
		return
168 +
	}
169 +
	if err := setSetting(a.DB, "category_label.reading", reading); err != nil {
170 +
		web.RedirectWithError(w, r, "/admin", "Failed to save labels")
171 +
		return
172 +
	}
173 +
	_ = setSetting(a.DB, "category_label.read", read)
174 +
	_ = setSetting(a.DB, "category_label.want", want)
175 +
	web.RedirectWithSuccess(w, r, "/admin", "Labels updated")
176 +
}
177 +
178 +
func (a *App) adminSearch(w http.ResponseWriter, r *http.Request) {
179 +
	hits, err := googleBooksSearch(r.Context(), r.URL.Query().Get("q"), a.GoogleBooksKey)
180 +
	if err != nil {
181 +
		a.Log.Warn("google books search failed", "err", err)
182 +
		web.WriteJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
183 +
		return
184 +
	}
185 +
	if hits == nil {
186 +
		hits = []SearchHit{}
187 +
	}
188 +
	web.WriteJSON(w, http.StatusOK, hits)
189 +
}
190 +
191 +
func (a *App) adminAddBook(w http.ResponseWriter, r *http.Request) {
192 +
	if err := r.ParseForm(); err != nil {
193 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
194 +
		return
195 +
	}
196 +
	status := r.FormValue("status")
197 +
	if !validStatus(status) {
198 +
		web.RedirectWithError(w, r, "/admin", "Invalid status")
199 +
		return
200 +
	}
201 +
	b := NewBook{Title: r.FormValue("title"), Authors: r.FormValue("authors"), Status: status}
202 +
	if v := strings.TrimSpace(r.FormValue("google_id")); v != "" {
203 +
		b.GoogleID = &v
204 +
	}
205 +
	if v := strings.TrimSpace(r.FormValue("isbn")); v != "" {
206 +
		b.ISBN = &v
207 +
	}
208 +
	if v := strings.TrimSpace(r.FormValue("cover_url")); v != "" {
209 +
		b.CoverURL = &v
210 +
	}
211 +
	if _, err := insertBook(a.DB, b); err != nil {
212 +
		a.Log.Error("insert book", "err", err)
213 +
		web.RedirectWithError(w, r, "/admin", "Failed to add book")
214 +
		return
215 +
	}
216 +
	web.RedirectWithSuccess(w, r, "/admin", "Book added")
217 +
}
218 +
219 +
func (a *App) adminUpdateStatus(w http.ResponseWriter, r *http.Request) {
220 +
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
221 +
	if err != nil {
222 +
		web.RedirectWithError(w, r, "/admin", "Bad id")
223 +
		return
224 +
	}
225 +
	if err := r.ParseForm(); err != nil {
226 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
227 +
		return
228 +
	}
229 +
	status := r.FormValue("status")
230 +
	if !validStatus(status) {
231 +
		web.RedirectWithError(w, r, "/admin", "Invalid status")
232 +
		return
233 +
	}
234 +
	_ = updateBookStatus(a.DB, id, status)
235 +
	web.RedirectWithSuccess(w, r, "/admin", "Status updated")
236 +
}
237 +
238 +
func (a *App) adminUpdateNotes(w http.ResponseWriter, r *http.Request) {
239 +
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
240 +
	if err != nil {
241 +
		web.RedirectWithError(w, r, "/admin", "Bad id")
242 +
		return
243 +
	}
244 +
	if err := r.ParseForm(); err != nil {
245 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
246 +
		return
247 +
	}
248 +
	notes := strings.TrimSpace(r.FormValue("notes"))
249 +
	var n *string
250 +
	if notes != "" {
251 +
		n = &notes
252 +
	}
253 +
	_ = updateBookNotes(a.DB, id, n)
254 +
	web.RedirectWithSuccess(w, r, "/admin", "Notes saved")
255 +
}
256 +
257 +
func (a *App) adminDeleteBook(w http.ResponseWriter, r *http.Request) {
258 +
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
259 +
	if err == nil {
260 +
		_ = deleteBook(a.DB, id)
261 +
	}
262 +
	web.RedirectWithSuccess(w, r, "/admin", "Book removed")
263 +
}
apps/library-go/main.go (added) +49 −0
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/crates-go/auth"
11 +
	"github.com/stevedylandev/andromeda/crates-go/config"
12 +
)
13 +
14 +
func main() {
15 +
	config.LoadDotEnv(".env")
16 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 +
18 +
	dbPath := config.Getenv("LIBRARY_DB_PATH", "library.sqlite")
19 +
	db, err := openDB(dbPath)
20 +
	if err != nil {
21 +
		log.Fatal(err)
22 +
	}
23 +
	defer db.Close()
24 +
25 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
26 +
	if err := sessions.EnsureSchema(); err != nil {
27 +
		log.Fatal(err)
28 +
	}
29 +
	sessions.PruneExpired()
30 +
31 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
32 +
	app := &App{
33 +
		DB:             db,
34 +
		Log:            logger,
35 +
		Templates:      tmpl,
36 +
		Sessions:       sessions,
37 +
		AdminPassword:  os.Getenv("ADMIN_PASSWORD"),
38 +
		GoogleBooksKey: os.Getenv("GOOGLE_BOOKS_API_KEY"),
39 +
		CookieSecure:   sessions.CookieSecure,
40 +
		BaseURL:        config.Getenv("BASE_URL", "http://localhost:3000"),
41 +
		DisplayMode:    parseDisplayMode(config.Getenv("LIBRARY_DISPLAY_MODE", "inline")),
42 +
	}
43 +
44 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
45 +
	logger.Info("library-go server running", "addr", addr)
46 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
47 +
		log.Fatal(err)
48 +
	}
49 +
}
apps/library-go/routes.go (added) +36 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/crates-go/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("GET /admin/search", requireSession(a.adminSearch))
26 +
	mux.HandleFunc("POST /admin/categories/labels", requireSession(a.adminUpdateLabels))
27 +
	mux.HandleFunc("POST /admin/add", requireSession(a.adminAddBook))
28 +
	mux.HandleFunc("POST /admin/books/{id}/status", requireSession(a.adminUpdateStatus))
29 +
	mux.HandleFunc("POST /admin/books/{id}/notes", requireSession(a.adminUpdateNotes))
30 +
	mux.HandleFunc("POST /admin/books/{id}/delete", requireSession(a.adminDeleteBook))
31 +
32 +
	mux.HandleFunc("GET /api/books", a.apiListBooks)
33 +
	mux.HandleFunc("GET /api/books/{id}", a.apiGetBook)
34 +
35 +
	return mux
36 +
}
apps/library-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/library-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/library-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/library-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/library-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/library-go/static/styles.css (added) +243 −0
1 +
/* library — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
.logo h1 {
6 +
  font-size: 28px;
7 +
  font-weight: 700;
8 +
  text-transform: uppercase;
9 +
}
10 +
11 +
/* Inline sections */
12 +
13 +
.section {
14 +
  width: 100%;
15 +
  display: flex;
16 +
  flex-direction: column;
17 +
  gap: 0;
18 +
  margin-bottom: 2rem;
19 +
}
20 +
21 +
.section-label {
22 +
  font-size: 11px;
23 +
  font-weight: 600;
24 +
  text-transform: uppercase;
25 +
  letter-spacing: 0.12em;
26 +
  opacity: 0.4;
27 +
  margin-bottom: 0.5rem;
28 +
}
29 +
30 +
/* Books list */
31 +
32 +
.books-list {
33 +
  width: 100%;
34 +
  display: flex;
35 +
  flex-direction: column;
36 +
  gap: 1.25rem;
37 +
}
38 +
39 +
.book-card {
40 +
  display: flex;
41 +
  gap: 1rem;
42 +
  padding: 1rem 0;
43 +
  border-bottom: 1px solid #333;
44 +
}
45 +
46 +
.book-card:last-child {
47 +
  border-bottom: none;
48 +
}
49 +
50 +
.book-cover {
51 +
  width: 72px;
52 +
  height: 108px;
53 +
  object-fit: cover;
54 +
  background: #1e1c1f;
55 +
  flex-shrink: 0;
56 +
  display: block;
57 +
}
58 +
59 +
.book-cover.placeholder {
60 +
  background: #1e1c1f;
61 +
}
62 +
63 +
.book-info {
64 +
  display: flex;
65 +
  flex-direction: column;
66 +
  gap: 0.4rem;
67 +
  flex: 1;
68 +
  min-width: 0;
69 +
}
70 +
71 +
.book-title {
72 +
  font-size: 16px;
73 +
  font-weight: 400;
74 +
  line-height: 1.4;
75 +
}
76 +
77 +
.book-authors {
78 +
  font-size: 14px;
79 +
  opacity: 0.7;
80 +
}
81 +
82 +
.book-meta {
83 +
  font-size: 12px;
84 +
  opacity: 0.5;
85 +
}
86 +
87 +
.book-notes {
88 +
  font-size: 13px;
89 +
  opacity: 0.7;
90 +
  line-height: 1.5;
91 +
}
92 +
93 +
.no-books {
94 +
  text-align: center;
95 +
  opacity: 0.5;
96 +
  padding: 2rem;
97 +
}
98 +
99 +
/* Admin */
100 +
101 +
.admin-form {
102 +
  display: flex;
103 +
  flex-direction: column;
104 +
  gap: 0.75rem;
105 +
  width: 100%;
106 +
  margin-bottom: 1.5rem;
107 +
}
108 +
109 +
.admin-form h3 {
110 +
  font-size: 14px;
111 +
  font-weight: 400;
112 +
  opacity: 0.5;
113 +
}
114 +
115 +
.hint {
116 +
  font-size: 12px;
117 +
  opacity: 0.5;
118 +
  line-height: 1.4;
119 +
}
120 +
121 +
.search-row {
122 +
  display: flex;
123 +
  gap: 0.5rem;
124 +
  width: 100%;
125 +
}
126 +
127 +
.search-row input {
128 +
  flex: 1;
129 +
}
130 +
131 +
.search-status {
132 +
  font-size: 12px;
133 +
}
134 +
135 +
.search-results {
136 +
  display: flex;
137 +
  flex-direction: column;
138 +
  gap: 0.5rem;
139 +
  width: 100%;
140 +
}
141 +
142 +
.book-card.hit,
143 +
.book-card.admin {
144 +
  border: 1px solid #333;
145 +
  padding: 0.75rem;
146 +
  border-bottom: 1px solid #333;
147 +
}
148 +
149 +
.book-card.admin .inline {
150 +
  display: flex;
151 +
  gap: 0.5rem;
152 +
  align-items: center;
153 +
  margin-top: 0.4rem;
154 +
}
155 +
156 +
.book-card.admin .notes-form {
157 +
  flex-direction: column;
158 +
  align-items: stretch;
159 +
}
160 +
161 +
.book-card.admin textarea {
162 +
  width: 100%;
163 +
  min-height: 1.6rem;
164 +
  font-family: inherit;
165 +
  font-size: 13px;
166 +
  line-height: 1.4;
167 +
  background: #121113;
168 +
  color: #ffffff;
169 +
  border: 1px solid #333;
170 +
  padding: 0.3rem 0.4rem;
171 +
  resize: vertical;
172 +
}
173 +
174 +
.admin-subs {
175 +
  width: 100%;
176 +
  display: flex;
177 +
  flex-direction: column;
178 +
  gap: 0.75rem;
179 +
}
180 +
181 +
.admin-subs h3 {
182 +
  font-size: 14px;
183 +
  opacity: 0.5;
184 +
  font-weight: 400;
185 +
}
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 +
@media (max-width: 480px) {
198 +
  .book-card {
199 +
    gap: 0.75rem;
200 +
  }
201 +
  .book-cover {
202 +
    width: 56px;
203 +
    height: 84px;
204 +
  }
205 +
  .book-title {
206 +
    font-size: 14px;
207 +
  }
208 +
}
209 +
210 +
.scan-modal {
211 +
  position: fixed;
212 +
  inset: 0;
213 +
  background: rgba(0, 0, 0, 0.85);
214 +
  display: flex;
215 +
  align-items: center;
216 +
  justify-content: center;
217 +
  z-index: 1000;
218 +
}
219 +
220 +
.scan-modal[hidden] {
221 +
  display: none;
222 +
}
223 +
224 +
.scan-inner {
225 +
  display: flex;
226 +
  flex-direction: column;
227 +
  align-items: center;
228 +
  gap: 12px;
229 +
  width: min(90vw, 480px);
230 +
}
231 +
232 +
.scan-inner video {
233 +
  width: 100%;
234 +
  max-height: 70vh;
235 +
  background: #000;
236 +
  border-radius: 8px;
237 +
}
238 +
239 +
.scan-status {
240 +
  color: #eee;
241 +
  font-size: 14px;
242 +
  margin: 0;
243 +
}
apps/library-go/templates/admin.html (added) +361 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Library | Admin</title>
14 +
  </head>
15 +
  <body>
16 +
    <div class="header">
17 +
      <a href="/" class="logo"><h1>LIBRARY</h1></a>
18 +
      <nav class="links">
19 +
        <a href="/admin/logout">logout</a>
20 +
      </nav>
21 +
    </div>
22 +
23 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
24 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
25 +
26 +
    <section class="admin-form">
27 +
      <h3>Category Labels</h3>
28 +
      <form method="POST" action="/admin/categories/labels" class="labels-form">
29 +
        <label>
30 +
          <span>Reading</span>
31 +
          <input type="text" name="reading" value="{{.Labels.Reading}}" required />
32 +
        </label>
33 +
        <label>
34 +
          <span>Read</span>
35 +
          <input type="text" name="read" value="{{.Labels.Read}}" required />
36 +
        </label>
37 +
        <label>
38 +
          <span>Want to Read</span>
39 +
          <input type="text" name="want" value="{{.Labels.Want}}" required />
40 +
        </label>
41 +
        <button type="submit">Save labels</button>
42 +
      </form>
43 +
    </section>
44 +
45 +
    <section class="admin-form">
46 +
      <h3>Search Books (Google)</h3>
47 +
      <div class="search-row">
48 +
        <input type="text" id="book-query" placeholder="title, author, isbn" />
49 +
        <button type="button" id="search-btn" onclick="searchBooks()">Search</button>
50 +
        <button type="button" id="scan-btn" onclick="openScanner()" hidden>Scan</button>
51 +
      </div>
52 +
      <div id="search-status" class="search-status" style="display:none;"></div>
53 +
      <div id="search-results" class="search-results"></div>
54 +
    </section>
55 +
56 +
    <section class="admin-form">
57 +
      <h3>Search Library</h3>
58 +
      <form method="GET" action="/admin" class="search-row">
59 +
        <input type="text" name="q" placeholder="title, author, isbn" value="{{.LibraryQuery}}" />
60 +
        <button type="submit">Search</button>
61 +
        {{if .LibrarySearched}}
62 +
        <a href="/admin" class="hint">clear</a>
63 +
        {{end}}
64 +
      </form>
65 +
      {{if .LibrarySearched}}
66 +
        {{if not .LibraryResults}}
67 +
        <p class="hint">No matches.</p>
68 +
        {{else}}
69 +
        <div class="books-list">
70 +
          {{$labels := .Labels}}
71 +
          {{range .LibraryResults}}
72 +
          <div class="book-card admin">
73 +
            {{if .CoverURL}}
74 +
            <img class="book-cover" src="{{.CoverURL}}" alt="" loading="lazy" />
75 +
            {{else}}
76 +
            <div class="book-cover placeholder"></div>
77 +
            {{end}}
78 +
            <div class="book-info">
79 +
              <h3 class="book-title">{{.Title}}</h3>
80 +
              <p class="book-authors">{{.Authors}}</p>
81 +
              {{if .ISBN}}<p class="book-meta">ISBN: {{.ISBN}}</p>{{end}}
82 +
              <form method="POST" action="/admin/books/{{.ID}}/status" class="inline">
83 +
                <select name="status" onchange="this.form.submit()">
84 +
                  <option value="read"{{if eq .Status "read"}} selected{{end}}>{{$labels.Read}}</option>
85 +
                  <option value="reading"{{if eq .Status "reading"}} selected{{end}}>{{$labels.Reading}}</option>
86 +
                  <option value="want"{{if eq .Status "want"}} selected{{end}}>{{$labels.Want}}</option>
87 +
                </select>
88 +
                <noscript><button type="submit">Save</button></noscript>
89 +
              </form>
90 +
              <form method="POST" action="/admin/books/{{.ID}}/notes" class="inline notes-form">
91 +
                <textarea name="notes" rows="5" placeholder="notes">{{.Notes}}</textarea>
92 +
                <button type="submit">Save notes</button>
93 +
              </form>
94 +
              <form method="POST" action="/admin/books/{{.ID}}/delete" class="inline">
95 +
                <button type="submit" class="danger">Delete</button>
96 +
              </form>
97 +
            </div>
98 +
          </div>
99 +
          {{end}}
100 +
        </div>
101 +
        {{end}}
102 +
      {{end}}
103 +
    </section>
104 +
105 +
    <div id="scan-modal" class="scan-modal" hidden>
106 +
      <div class="scan-inner">
107 +
        <video id="scan-video" playsinline muted></video>
108 +
        <p id="scan-status" class="scan-status">Point camera at barcode</p>
109 +
        <button type="button" onclick="closeScanner()">Cancel</button>
110 +
      </div>
111 +
    </div>
112 +
113 +
    <section class="admin-subs">
114 +
      <h3>Library ({{len .Books}})</h3>
115 +
      {{if not .Books}}
116 +
      <p class="hint">No books yet. Search above to add one.</p>
117 +
      {{else}}
118 +
      <div class="books-list">
119 +
        {{$labels := .Labels}}
120 +
        {{range .Books}}
121 +
        <div class="book-card admin">
122 +
          {{if .CoverURL}}
123 +
          <img class="book-cover" src="{{.CoverURL}}" alt="" loading="lazy" />
124 +
          {{else}}
125 +
          <div class="book-cover placeholder"></div>
126 +
          {{end}}
127 +
          <div class="book-info">
128 +
            <h3 class="book-title">{{.Title}}</h3>
129 +
            <p class="book-authors">{{.Authors}}</p>
130 +
            {{if .ISBN}}<p class="book-meta">ISBN: {{.ISBN}}</p>{{end}}
131 +
            <form method="POST" action="/admin/books/{{.ID}}/status" class="inline">
132 +
              <select name="status" onchange="this.form.submit()">
133 +
                <option value="read"{{if eq .Status "read"}} selected{{end}}>{{$labels.Read}}</option>
134 +
                <option value="reading"{{if eq .Status "reading"}} selected{{end}}>{{$labels.Reading}}</option>
135 +
                <option value="want"{{if eq .Status "want"}} selected{{end}}>{{$labels.Want}}</option>
136 +
              </select>
137 +
              <noscript><button type="submit">Save</button></noscript>
138 +
            </form>
139 +
            <form method="POST" action="/admin/books/{{.ID}}/notes" class="inline notes-form">
140 +
              <textarea name="notes" rows="5" placeholder="notes">{{.Notes}}</textarea>
141 +
              <button type="submit">Save notes</button>
142 +
            </form>
143 +
            <form method="POST" action="/admin/books/{{.ID}}/delete" class="inline">
144 +
              <button type="submit" class="danger">Delete</button>
145 +
            </form>
146 +
          </div>
147 +
        </div>
148 +
        {{end}}
149 +
      </div>
150 +
      {{end}}
151 +
    </section>
152 +
153 +
    <div id="category-labels-data"
154 +
         data-want="{{.Labels.Want}}"
155 +
         data-reading="{{.Labels.Reading}}"
156 +
         data-read="{{.Labels.Read}}"
157 +
         hidden></div>
158 +
159 +
    <script src="https://unpkg.com/@zxing/browser@0.1.5/umd/zxing-browser.min.js"></script>
160 +
    <script>
161 +
      (function() {
162 +
        const el = document.getElementById('category-labels-data');
163 +
        window.__categoryLabels = {
164 +
          want: el.dataset.want,
165 +
          reading: el.dataset.reading,
166 +
          read: el.dataset.read,
167 +
        };
168 +
      })();
169 +
170 +
      async function searchBooks() {
171 +
        const q = document.getElementById('book-query').value.trim();
172 +
        if (!q) return;
173 +
        const btn = document.getElementById('search-btn');
174 +
        const status = document.getElementById('search-status');
175 +
        const results = document.getElementById('search-results');
176 +
        btn.disabled = true;
177 +
        btn.textContent = 'Searching...';
178 +
        status.style.display = 'none';
179 +
        results.innerHTML = '';
180 +
        try {
181 +
          const resp = await fetch('/admin/search?q=' + encodeURIComponent(q));
182 +
          const data = await resp.json();
183 +
          if (!resp.ok) {
184 +
            status.textContent = data.error || 'Search failed';
185 +
            status.className = 'search-status error';
186 +
            status.style.display = 'block';
187 +
            return;
188 +
          }
189 +
          if (!data.length) {
190 +
            status.textContent = 'No results';
191 +
            status.className = 'search-status';
192 +
            status.style.display = 'block';
193 +
            return;
194 +
          }
195 +
          data.forEach(function(hit) {
196 +
            results.appendChild(renderHit(hit));
197 +
          });
198 +
        } catch (e) {
199 +
          status.textContent = 'Request failed';
200 +
          status.className = 'search-status error';
201 +
          status.style.display = 'block';
202 +
        } finally {
203 +
          btn.disabled = false;
204 +
          btn.textContent = 'Search';
205 +
        }
206 +
      }
207 +
208 +
      function renderHit(hit) {
209 +
        const card = document.createElement('form');
210 +
        card.method = 'POST';
211 +
        card.action = '/admin/add';
212 +
        card.className = 'book-card hit';
213 +
214 +
        const cover = document.createElement('div');
215 +
        if (hit.cover_url) {
216 +
          const img = document.createElement('img');
217 +
          img.src = hit.cover_url;
218 +
          img.className = 'book-cover';
219 +
          img.loading = 'lazy';
220 +
          card.appendChild(img);
221 +
        } else {
222 +
          cover.className = 'book-cover placeholder';
223 +
          card.appendChild(cover);
224 +
        }
225 +
226 +
        const info = document.createElement('div');
227 +
        info.className = 'book-info';
228 +
        info.innerHTML =
229 +
          '<h3 class="book-title"></h3>' +
230 +
          '<p class="book-authors"></p>' +
231 +
          (hit.isbn ? '<p class="book-meta">ISBN: ' + escapeHtml(hit.isbn) + '</p>' : '');
232 +
        info.querySelector('.book-title').textContent = hit.title;
233 +
        info.querySelector('.book-authors').textContent = hit.authors;
234 +
235 +
        const hidden = function(name, value) {
236 +
          const el = document.createElement('input');
237 +
          el.type = 'hidden';
238 +
          el.name = name;
239 +
          el.value = value || '';
240 +
          return el;
241 +
        };
242 +
        info.appendChild(hidden('google_id', hit.google_id));
243 +
        info.appendChild(hidden('title', hit.title));
244 +
        info.appendChild(hidden('authors', hit.authors));
245 +
        info.appendChild(hidden('isbn', hit.isbn));
246 +
        info.appendChild(hidden('cover_url', hit.cover_url));
247 +
248 +
        const select = document.createElement('select');
249 +
        select.name = 'status';
250 +
        const labels = window.__categoryLabels || { want: 'Want to Read', reading: 'Reading', read: 'Read' };
251 +
        ['want', 'reading', 'read'].forEach(function(s) {
252 +
          const o = document.createElement('option');
253 +
          o.value = s;
254 +
          o.textContent = labels[s];
255 +
          select.appendChild(o);
256 +
        });
257 +
        info.appendChild(select);
258 +
259 +
        const btn = document.createElement('button');
260 +
        btn.type = 'submit';
261 +
        btn.textContent = 'Add';
262 +
        info.appendChild(btn);
263 +
264 +
        card.appendChild(info);
265 +
        return card;
266 +
      }
267 +
268 +
      function escapeHtml(s) {
269 +
        return String(s || '').replace(/[&<>"']/g, function(c) {
270 +
          return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"})[c];
271 +
        });
272 +
      }
273 +
274 +
      let scanStream = null;
275 +
      let scanRaf = null;
276 +
      let zxingControls = null;
277 +
278 +
      const hasNativeBarcode = 'BarcodeDetector' in window;
279 +
      const hasZxing = typeof ZXingBrowser !== 'undefined';
280 +
281 +
      (function initScan() {
282 +
        if ((hasNativeBarcode || hasZxing) && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
283 +
          document.getElementById('scan-btn').hidden = false;
284 +
        }
285 +
      })();
286 +
287 +
      async function openScanner() {
288 +
        const modal = document.getElementById('scan-modal');
289 +
        const video = document.getElementById('scan-video');
290 +
        const status = document.getElementById('scan-status');
291 +
        status.textContent = 'Point camera at barcode';
292 +
        modal.hidden = false;
293 +
294 +
        const onHit = (isbn) => {
295 +
          closeScanner();
296 +
          document.getElementById('book-query').value = isbn;
297 +
          searchBooks();
298 +
        };
299 +
300 +
        try {
301 +
          let detector = null;
302 +
          if (hasNativeBarcode) {
303 +
            try {
304 +
              detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] });
305 +
            } catch (_) {
306 +
              detector = null;
307 +
            }
308 +
          }
309 +
310 +
          if (detector) {
311 +
            scanStream = await navigator.mediaDevices.getUserMedia({
312 +
              video: { facingMode: 'environment' }
313 +
            });
314 +
            video.srcObject = scanStream;
315 +
            await video.play();
316 +
            const tick = async () => {
317 +
              if (!scanStream) return;
318 +
              try {
319 +
                const codes = await detector.detect(video);
320 +
                if (codes.length) return onHit(codes[0].rawValue);
321 +
              } catch (_) {}
322 +
              scanRaf = requestAnimationFrame(tick);
323 +
            };
324 +
            tick();
325 +
            return;
326 +
          }
327 +
328 +
          if (hasZxing) {
329 +
            const reader = new ZXingBrowser.BrowserMultiFormatReader();
330 +
            zxingControls = await reader.decodeFromVideoDevice(undefined, video, (result, err, controls) => {
331 +
              if (result) {
332 +
                controls.stop();
333 +
                zxingControls = null;
334 +
                onHit(result.getText());
335 +
              }
336 +
            });
337 +
            return;
338 +
          }
339 +
340 +
          status.textContent = 'Scanner not supported';
341 +
        } catch (e) {
342 +
          status.textContent = 'Camera unavailable';
343 +
        }
344 +
      }
345 +
346 +
      function closeScanner() {
347 +
        if (scanRaf) cancelAnimationFrame(scanRaf);
348 +
        scanRaf = null;
349 +
        if (zxingControls) {
350 +
          try { zxingControls.stop(); } catch (_) {}
351 +
          zxingControls = null;
352 +
        }
353 +
        if (scanStream) {
354 +
          scanStream.getTracks().forEach(function(t) { t.stop(); });
355 +
          scanStream = null;
356 +
        }
357 +
        document.getElementById('scan-modal').hidden = true;
358 +
      }
359 +
    </script>
360 +
  </body>
361 +
</html>
apps/library-go/templates/index.html (added) +73 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Library</title>
14 +
    <meta name="description" content="Personal book tracker" />
15 +
    <meta property="og:url" content="{{.BaseURL}}" />
16 +
    <meta property="og:type" content="website" />
17 +
    <meta property="og:title" content="Library" />
18 +
    <meta property="og:description" content="Personal book tracker" />
19 +
    <meta property="og:image" content="{{.BaseURL}}/static/og.png" />
20 +
    <meta name="twitter:card" content="summary_large_image" />
21 +
    <meta property="twitter:url" content="{{.BaseURL}}" />
22 +
    <meta name="twitter:title" content="Library" />
23 +
    <meta name="twitter:description" content="Personal book tracker" />
24 +
    <meta name="twitter:image" content="{{.BaseURL}}/static/og.png" />
25 +
  </head>
26 +
  <body>
27 +
    <div class="header">
28 +
      <a href="/" class="logo"><h1>LIBRARY</h1></a>
29 +
      <nav class="links">
30 +
        {{if .NavMode}}
31 +
        {{range .NavCategories}}
32 +
        <a href="/?category={{.Slug}}"{{if .Active}} class="active"{{end}}>{{.Label}}</a>
33 +
        {{end}}
34 +
        {{end}}
35 +
        <a href="/admin">add</a>
36 +
      </nav>
37 +
    </div>
38 +
39 +
    {{if not .Sections}}
40 +
    <p class="no-books">No books yet.</p>
41 +
    {{else}}
42 +
    {{range .Sections}}
43 +
    <div class="section">
44 +
      {{if not $.NavMode}}
45 +
      <h2 class="section-label">{{.Label}}</h2>
46 +
      {{end}}
47 +
      {{if not .Books}}
48 +
      <p class="no-books">No books in {{.Label}}.</p>
49 +
      {{else}}
50 +
      <div class="books-list">
51 +
        {{range .Books}}
52 +
        <article class="book-card">
53 +
          {{if .CoverURL}}
54 +
          <img class="book-cover" src="{{.CoverURL}}" alt="" loading="lazy" />
55 +
          {{else}}
56 +
          <div class="book-cover placeholder"></div>
57 +
          {{end}}
58 +
          <div class="book-info">
59 +
            <h3 class="book-title">{{.Title}}</h3>
60 +
            <p class="book-authors">{{.Authors}}</p>
61 +
            {{if .Notes}}
62 +
            <p class="book-notes">{{.Notes}}</p>
63 +
            {{end}}
64 +
          </div>
65 +
        </article>
66 +
        {{end}}
67 +
      </div>
68 +
      {{end}}
69 +
    </div>
70 +
    {{end}}
71 +
    {{end}}
72 +
  </body>
73 +
</html>
apps/library-go/templates/login.html (added) +26 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" />
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" />
12 +
    <link rel="manifest" href="/static/site.webmanifest" />
13 +
    <title>Library | Login</title>
14 +
  </head>
15 +
  <body>
16 +
    <a href="/" class="header">
17 +
      <h1>LIBRARY</h1>
18 +
    </a>
19 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
20 +
    <form class="admin-form" method="POST" action="/admin/login">
21 +
      <label for="password">Password</label>
22 +
      <input type="password" id="password" name="password" required autofocus />
23 +
      <button type="submit">Login</button>
24 +
    </form>
25 +
  </body>
26 +
</html>
apps/og-go/.env.example (added) +1 −0
1 +
PORT=3000
apps/og-go/Dockerfile (added) +17 −0
1 +
# Build from repo root: docker build -t og-go -f apps/og-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/og-go/go.mod apps/og-go/go.sum ./apps/og-go/
6 +
WORKDIR /app/apps/og-go
7 +
RUN go mod download
8 +
COPY apps/og-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /og-go .
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 /og-go /usr/local/bin/og-go
14 +
ENV HOST=0.0.0.0
15 +
ENV PORT=3000
16 +
EXPOSE 3000
17 +
CMD ["og-go"]
apps/og-go/README.md (added) +34 −0
1 +
# og-go
2 +
3 +
Go rewrite of [og](../og). Open Graph tag inspector for any URL.
4 +
5 +
## Quickstart
6 +
7 +
```bash
8 +
cp .env.example .env
9 +
go run .
10 +
```
11 +
12 +
Then open `http://localhost:3000`.
13 +
14 +
### Environment Variables
15 +
16 +
| Variable | Default | Description |
17 +
|---|---|---|
18 +
| `HOST` | `0.0.0.0` | Bind host |
19 +
| `PORT` | `3000` | Server port |
20 +
21 +
## Routes
22 +
23 +
- `GET /` — search form
24 +
- `POST /check` — inspect a URL (form field: `url`)
25 +
- `GET /static/*` — embedded favicon, styles, etc.
26 +
- `GET /assets/darkmatter.css` + `/assets/fonts/*` — served by `crates-go/darkmatter`
27 +
28 +
## Build
29 +
30 +
```bash
31 +
go build .
32 +
```
33 +
34 +
The binary embeds all templates and static assets.
apps/og-go/app.go (added) +36 −0
1 +
package main
2 +
3 +
import (
4 +
	"embed"
5 +
	"html/template"
6 +
	"log/slog"
7 +
)
8 +
9 +
//go:embed templates/*.html static/*
10 +
var appFS embed.FS
11 +
12 +
type App struct {
13 +
	Log       *slog.Logger
14 +
	Templates *template.Template
15 +
}
16 +
17 +
type tagKV struct {
18 +
	Key   string
19 +
	Value string
20 +
}
21 +
22 +
type linkTag struct {
23 +
	Rel   string
24 +
	Href  string
25 +
	Extra string
26 +
}
27 +
28 +
type resultsData struct {
29 +
	URL         string
30 +
	Error       string
31 +
	OGImage     string
32 +
	Favicon     string
33 +
	FoundTags   []tagKV
34 +
	MissingTags []string
35 +
	LinkTags    []linkTag
36 +
}
apps/og-go/docker-compose.yml (added) +11 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/og-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
    restart: unless-stopped
apps/og-go/go.mod (added) +16 −0
1 +
module github.com/stevedylandev/andromeda/apps/og-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
9 +
	golang.org/x/net v0.41.0
10 +
)
11 +
12 +
replace (
13 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
14 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
15 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
16 +
)
apps/og-go/go.sum (added) +2 −0
1 +
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
2 +
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
apps/og-go/handlers.go (added) +53 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"slices"
6 +
	"strings"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
10 +
11 +
var commonTags = []string{"og:title", "og:description", "og:image", "og:url", "og:type"}
12 +
13 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
14 +
	web.Render(a.Templates, w, "index.html", nil, a.Log)
15 +
}
16 +
17 +
func (a *App) checkHandler(w http.ResponseWriter, r *http.Request) {
18 +
	if err := r.ParseForm(); err != nil {
19 +
		web.Render(a.Templates, w, "results.html", resultsData{Error: "Bad request"}, a.Log)
20 +
		return
21 +
	}
22 +
	u := strings.TrimSpace(r.FormValue("url"))
23 +
	if u == "" {
24 +
		web.Render(a.Templates, w, "results.html", resultsData{Error: "Please enter a URL"}, a.Log)
25 +
		return
26 +
	}
27 +
	if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
28 +
		u = "https://" + u
29 +
	}
30 +
31 +
	res, err := fetchOGData(r.Context(), u)
32 +
	if err != nil {
33 +
		web.Render(a.Templates, w, "results.html", resultsData{URL: u, Error: err.Error()}, a.Log)
34 +
		return
35 +
	}
36 +
37 +
	data := resultsData{URL: u, OGImage: res.OGTags["og:image"], Favicon: res.Favicon}
38 +
	for _, tag := range commonTags {
39 +
		if v, ok := res.OGTags[tag]; ok {
40 +
			data.FoundTags = append(data.FoundTags, tagKV{Key: tag, Value: v})
41 +
		} else {
42 +
			data.MissingTags = append(data.MissingTags, tag)
43 +
		}
44 +
	}
45 +
	for _, key := range res.OGOrder {
46 +
		if slices.Contains(commonTags, key) {
47 +
			continue
48 +
		}
49 +
		data.FoundTags = append(data.FoundTags, tagKV{Key: key, Value: res.OGTags[key]})
50 +
	}
51 +
	data.LinkTags = res.LinkTags
52 +
	web.Render(a.Templates, w, "results.html", data, a.Log)
53 +
}
apps/og-go/main.go (added) +24 −0
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/crates-go/config"
11 +
)
12 +
13 +
func main() {
14 +
	config.LoadDotEnv(".env")
15 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
16 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
17 +
	app := &App{Log: logger, Templates: tmpl}
18 +
19 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
20 +
	logger.Info("og-go server running", "addr", addr)
21 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
22 +
		log.Fatal(err)
23 +
	}
24 +
}
apps/og-go/og.go (added) +165 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"errors"
6 +
	"fmt"
7 +
	"io"
8 +
	"net/http"
9 +
	"net/url"
10 +
	"strings"
11 +
	"time"
12 +
13 +
	"golang.org/x/net/html"
14 +
)
15 +
16 +
const userAgent = "Mozilla/5.0 (compatible; OGPreview/1.0)"
17 +
18 +
type ogResult struct {
19 +
	OGTags   map[string]string
20 +
	OGOrder  []string
21 +
	Favicon  string
22 +
	LinkTags []linkTag
23 +
}
24 +
25 +
func fetchOGData(ctx context.Context, target string) (*ogResult, error) {
26 +
	parsed, err := url.Parse(target)
27 +
	if err != nil {
28 +
		return nil, fmt.Errorf("Invalid URL: %w", err)
29 +
	}
30 +
	client := &http.Client{Timeout: 10 * time.Second}
31 +
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
32 +
	if err != nil {
33 +
		return nil, fmt.Errorf("Failed to create HTTP client: %w", err)
34 +
	}
35 +
	req.Header.Set("User-Agent", userAgent)
36 +
	resp, err := client.Do(req)
37 +
	if err != nil {
38 +
		return nil, fmt.Errorf("Failed to fetch URL: %w", err)
39 +
	}
40 +
	defer resp.Body.Close()
41 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
42 +
		return nil, fmt.Errorf("HTTP error: %s", resp.Status)
43 +
	}
44 +
	ct := resp.Header.Get("Content-Type")
45 +
	if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "application/xhtml") {
46 +
		return nil, fmt.Errorf("Not an HTML page (Content-Type: %s)", ct)
47 +
	}
48 +
	body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
49 +
	if err != nil {
50 +
		return nil, fmt.Errorf("Failed to read response body: %w", err)
51 +
	}
52 +
	doc, err := html.Parse(strings.NewReader(string(body)))
53 +
	if err != nil {
54 +
		return nil, errors.New("Failed to parse HTML")
55 +
	}
56 +
57 +
	res := &ogResult{OGTags: map[string]string{}}
58 +
	walk(doc, func(n *html.Node) {
59 +
		if n.Type != html.ElementNode {
60 +
			return
61 +
		}
62 +
		if strings.EqualFold(n.Data, "meta") {
63 +
			attrs := attrsOf(n)
64 +
			key := attrs["property"]
65 +
			if key == "" {
66 +
				key = attrs["name"]
67 +
			}
68 +
			if strings.HasPrefix(key, "og:") {
69 +
				if _, exists := res.OGTags[key]; !exists {
70 +
					res.OGTags[key] = attrs["content"]
71 +
					res.OGOrder = append(res.OGOrder, key)
72 +
				}
73 +
			}
74 +
		}
75 +
	})
76 +
77 +
	if image, ok := res.OGTags["og:image"]; ok {
78 +
		if u, err := parsed.Parse(image); err == nil {
79 +
			res.OGTags["og:image"] = u.String()
80 +
		}
81 +
	}
82 +
83 +
	res.Favicon = extractFavicon(doc, parsed)
84 +
	res.LinkTags = extractLinkTags(doc, parsed)
85 +
	return res, nil
86 +
}
87 +
88 +
func extractFavicon(doc *html.Node, base *url.URL) string {
89 +
	rels := []string{"icon", "shortcut icon", "apple-touch-icon"}
90 +
	for _, want := range rels {
91 +
		var found string
92 +
		walk(doc, func(n *html.Node) {
93 +
			if found != "" || n.Type != html.ElementNode || !strings.EqualFold(n.Data, "link") {
94 +
				return
95 +
			}
96 +
			attrs := attrsOf(n)
97 +
			if strings.EqualFold(strings.TrimSpace(attrs["rel"]), want) {
98 +
				if href := attrs["href"]; href != "" {
99 +
					if u, err := base.Parse(href); err == nil {
100 +
						found = u.String()
101 +
					}
102 +
				}
103 +
			}
104 +
		})
105 +
		if found != "" {
106 +
			return found
107 +
		}
108 +
	}
109 +
	if fb, err := base.Parse("/favicon.ico"); err == nil {
110 +
		return fb.String()
111 +
	}
112 +
	return ""
113 +
}
114 +
115 +
func extractLinkTags(doc *html.Node, base *url.URL) []linkTag {
116 +
	var head *html.Node
117 +
	walk(doc, func(n *html.Node) {
118 +
		if head != nil {
119 +
			return
120 +
		}
121 +
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "head") {
122 +
			head = n
123 +
		}
124 +
	})
125 +
	if head == nil {
126 +
		return nil
127 +
	}
128 +
	var out []linkTag
129 +
	walk(head, func(n *html.Node) {
130 +
		if n.Type != html.ElementNode || !strings.EqualFold(n.Data, "link") {
131 +
			return
132 +
		}
133 +
		attrs := attrsOf(n)
134 +
		href := attrs["href"]
135 +
		if href != "" {
136 +
			if u, err := base.Parse(href); err == nil {
137 +
				href = u.String()
138 +
			}
139 +
		}
140 +
		extras := []string{}
141 +
		for _, a := range n.Attr {
142 +
			if a.Key == "rel" || a.Key == "href" {
143 +
				continue
144 +
			}
145 +
			extras = append(extras, fmt.Sprintf(`%s="%s"`, a.Key, a.Val))
146 +
		}
147 +
		out = append(out, linkTag{Rel: attrs["rel"], Href: href, Extra: strings.Join(extras, " ")})
148 +
	})
149 +
	return out
150 +
}
151 +
152 +
func attrsOf(n *html.Node) map[string]string {
153 +
	out := make(map[string]string, len(n.Attr))
154 +
	for _, a := range n.Attr {
155 +
		out[strings.ToLower(a.Key)] = a.Val
156 +
	}
157 +
	return out
158 +
}
159 +
160 +
func walk(n *html.Node, visit func(*html.Node)) {
161 +
	visit(n)
162 +
	for c := n.FirstChild; c != nil; c = c.NextSibling {
163 +
		walk(c, visit)
164 +
	}
165 +
}
apps/og-go/routes.go (added) +17 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
func (a *App) routes() *http.ServeMux {
11 +
	mux := http.NewServeMux()
12 +
	mux.HandleFunc("GET /", a.indexHandler)
13 +
	mux.HandleFunc("POST /check", a.checkHandler)
14 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
15 +
	darkmatter.Mount(mux, "/assets")
16 +
	return mux
17 +
}
apps/og-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/og-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/og-go/static/icon.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

apps/og-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/og-go/static/styles.css (added) +181 −0
1 +
/* og — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
.index-container {
6 +
    width: 100%;
7 +
}
8 +
9 +
.description {
10 +
    opacity: 0.7;
11 +
}
12 +
13 +
.check-form {
14 +
    display: flex;
15 +
    flex-wrap: nowrap;
16 +
    gap: 0.5rem;
17 +
    width: 100%;
18 +
}
19 +
20 +
.check-form input {
21 +
    flex: 1;
22 +
}
23 +
24 +
.check-form input::placeholder {
25 +
    opacity: 0.3;
26 +
}
27 +
28 +
.check-form button {
29 +
    flex-shrink: 0;
30 +
    white-space: nowrap;
31 +
    font-weight: 700;
32 +
}
33 +
34 +
/* Results page */
35 +
36 +
.results-container {
37 +
    display: flex;
38 +
    flex-direction: column;
39 +
    gap: 1.5rem;
40 +
    width: 100%;
41 +
}
42 +
43 +
.results-header {
44 +
    display: flex;
45 +
    flex-direction: column;
46 +
    gap: 0.75rem;
47 +
    padding-bottom: 1rem;
48 +
    border-bottom: 1px solid #333;
49 +
}
50 +
51 +
.results-url a {
52 +
    word-break: break-all;
53 +
}
54 +
55 +
.label {
56 +
    display: block;
57 +
    font-size: 12px;
58 +
    opacity: 0.7;
59 +
    text-transform: uppercase;
60 +
    margin-bottom: 0.25rem;
61 +
}
62 +
63 +
.results-meta span:last-child {
64 +
    opacity: 0.5;
65 +
    font-size: 12px;
66 +
}
67 +
68 +
.error h2 {
69 +
    font-size: 12px;
70 +
    text-transform: uppercase;
71 +
    margin-bottom: 0.25rem;
72 +
}
73 +
74 +
.preview-section {
75 +
    display: flex;
76 +
    flex-direction: column;
77 +
    gap: 0.5rem;
78 +
}
79 +
80 +
.preview-section h2 {
81 +
    font-size: 12px;
82 +
    text-transform: uppercase;
83 +
    opacity: 0.5;
84 +
}
85 +
86 +
.image-preview {
87 +
    border: 1px solid #333;
88 +
    overflow: hidden;
89 +
}
90 +
91 +
.image-preview img {
92 +
    display: block;
93 +
    width: 100%;
94 +
    height: auto;
95 +
}
96 +
97 +
.tag-section {
98 +
    display: flex;
99 +
    flex-direction: column;
100 +
    width: 100%;
101 +
}
102 +
103 +
.tag-section h2 {
104 +
    font-size: 12px;
105 +
    text-transform: uppercase;
106 +
    opacity: 0.5;
107 +
    margin-bottom: 0.5rem;
108 +
}
109 +
110 +
.tag-item {
111 +
    display: flex;
112 +
    gap: 1rem;
113 +
    padding: 0.75rem 0;
114 +
    border-bottom: 1px solid #333;
115 +
    font-size: 14px;
116 +
}
117 +
118 +
.tag-item.found {
119 +
    border-left: 2px solid #ffffff;
120 +
    padding-left: 0.75rem;
121 +
}
122 +
123 +
.tag-item.missing {
124 +
    border-left: 2px solid #555;
125 +
    padding-left: 0.75rem;
126 +
    opacity: 0.3;
127 +
}
128 +
129 +
.tag-key {
130 +
    font-weight: 700;
131 +
    min-width: 140px;
132 +
    max-width: 200px;
133 +
    opacity: 0.7;
134 +
    word-break: break-all;
135 +
}
136 +
137 +
.tag-value {
138 +
    word-break: break-all;
139 +
    min-width: 0;
140 +
    flex: 1;
141 +
}
142 +
143 +
.tag-item.missing .tag-value {
144 +
    font-style: italic;
145 +
}
146 +
147 +
.tag-extra {
148 +
    display: block;
149 +
    font-size: 12px;
150 +
    opacity: 0.5;
151 +
    margin-top: 0.15rem;
152 +
}
153 +
154 +
.empty-state {
155 +
    opacity: 0.5;
156 +
    font-size: 14px;
157 +
}
158 +
159 +
.back-link {
160 +
    display: inline-block;
161 +
    font-size: 12px;
162 +
    opacity: 0.5;
163 +
    padding-top: 1rem;
164 +
    border-top: 1px solid #333;
165 +
    width: 100%;
166 +
}
167 +
168 +
.back-link:hover {
169 +
    opacity: 0.7;
170 +
}
171 +
172 +
@media (max-width: 480px) {
173 +
    .tag-item {
174 +
        flex-direction: column;
175 +
        gap: 0.25rem;
176 +
    }
177 +
178 +
    .tag-key {
179 +
        min-width: unset;
180 +
    }
181 +
}
apps/og-go/templates/base.html (added) +26 −0
1 +
{{define "base.html"}}<!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 +
    <title>{{block "title" .}}OG{{end}}</title>
8 +
    <meta name="description" content="Check and preview OpenGraph tags for any URL">
9 +
    <meta property="og:title" content="OG Preview">
10 +
    <meta property="og:description" content="Check and preview OpenGraph tags for any URL">
11 +
    <meta property="og:image" content="/static/og.png">
12 +
    <meta property="og:type" content="website">
13 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
14 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
15 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
16 +
    <link rel="manifest" href="/static/site.webmanifest">
17 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
18 +
    <link rel="stylesheet" href="/static/styles.css">
19 +
</head>
20 +
<body>
21 +
    <header class="header">
22 +
        <a href="/" class="logo">OG</a>
23 +
    </header>
24 +
    {{block "content" .}}{{end}}
25 +
</body>
26 +
</html>{{end}}
apps/og-go/templates/index.html (added) +17 −0
1 +
{{define "index.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}OG{{end}}
3 +
{{define "content"}}
4 +
<div class="index-container">
5 +
    <form action="/check" method="POST" class="check-form">
6 +
        <input
7 +
            type="text"
8 +
            name="url"
9 +
            placeholder="example.com"
10 +
            required
11 +
            autocomplete="url"
12 +
            autofocus
13 +
        >
14 +
        <button type="submit">Check</button>
15 +
    </form>
16 +
</div>
17 +
{{end}}
apps/og-go/templates/results.html (added) +86 −0
1 +
{{define "results.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Results — {{.URL}}{{end}}
3 +
{{define "content"}}
4 +
<div class="results-container">
5 +
    <div class="results-header">
6 +
        <div class="results-url">
7 +
            <span class="label">URL</span>
8 +
            <a href="{{.URL}}" target="_blank" rel="noopener">{{.URL}}</a>
9 +
        </div>
10 +
    </div>
11 +
12 +
    {{if .Error}}
13 +
    <div class="error">
14 +
        <h2>Error</h2>
15 +
        <p>{{.Error}}</p>
16 +
    </div>
17 +
    {{else}}
18 +
19 +
    {{if .OGImage}}
20 +
    <div class="preview-section">
21 +
        <h2>Image Preview</h2>
22 +
        <div class="image-preview">
23 +
            <img src="{{.OGImage}}" alt="OG Image preview" loading="lazy">
24 +
        </div>
25 +
    </div>
26 +
    {{end}}
27 +
28 +
    {{if .Favicon}}
29 +
    <div class="preview-section">
30 +
        <h2>Favicon</h2>
31 +
        <img src="{{.Favicon}}" alt="Favicon" width="32" height="32">
32 +
    </div>
33 +
    {{end}}
34 +
35 +
    <div class="tag-section">
36 +
        <h2>Found Tags</h2>
37 +
        {{if not .FoundTags}}
38 +
        <p class="empty-state">No OpenGraph tags found.</p>
39 +
        {{else}}
40 +
        {{range .FoundTags}}
41 +
        <div class="tag-item found">
42 +
            <span class="tag-key">{{.Key}}</span>
43 +
            <span class="tag-value">{{.Value}}</span>
44 +
        </div>
45 +
        {{end}}
46 +
        {{end}}
47 +
    </div>
48 +
49 +
    <div class="tag-section">
50 +
        <h2>Missing Tags</h2>
51 +
        {{if not .MissingTags}}
52 +
        <p class="empty-state">All common tags present.</p>
53 +
        {{else}}
54 +
        {{range .MissingTags}}
55 +
        <div class="tag-item missing">
56 +
            <span class="tag-key">{{.}}</span>
57 +
            <span class="tag-value">not found</span>
58 +
        </div>
59 +
        {{end}}
60 +
        {{end}}
61 +
    </div>
62 +
63 +
    {{if .LinkTags}}
64 +
    <div class="tag-section">
65 +
        <h2>Link Tags</h2>
66 +
        {{range .LinkTags}}
67 +
        <div class="tag-item found">
68 +
            <span class="tag-key">{{if .Rel}}{{.Rel}}{{else}}link{{end}}</span>
69 +
            <span class="tag-value">
70 +
                {{if .Href}}
71 +
                <a href="{{.Href}}" target="_blank" rel="noopener">{{.Href}}</a>
72 +
                {{else}}
73 +
                <span class="tag-extra">no href</span>
74 +
                {{end}}
75 +
                {{if .Extra}}
76 +
                <span class="tag-extra">{{.Extra}}</span>
77 +
                {{end}}
78 +
            </span>
79 +
        </div>
80 +
        {{end}}
81 +
    </div>
82 +
    {{end}}
83 +
84 +
    {{end}}
85 +
</div>
86 +
{{end}}
apps/posts-go/.env.example (added) +7 −0
1 +
POSTS_PASSWORD=changeme
2 +
POSTS_DB_PATH=posts.sqlite
3 +
UPLOADS_DIR=uploads
4 +
COOKIE_SECURE=false
5 +
HOST=127.0.0.1
6 +
PORT=3000
7 +
SITE_URL=http://localhost:3000
apps/posts-go/Dockerfile (added) +19 −0
1 +
# Build from repo root: docker build -t posts-go -f apps/posts-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/posts-go/go.mod apps/posts-go/go.sum ./apps/posts-go/
6 +
WORKDIR /app/apps/posts-go
7 +
RUN go mod download
8 +
COPY apps/posts-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /posts-go .
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 /posts-go /usr/local/bin/posts-go
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
ENV UPLOADS_DIR=/data/uploads
18 +
EXPOSE 3000
19 +
CMD ["posts-go"]
apps/posts-go/README.md (added) +16 −0
1 +
# posts-go
2 +
3 +
Go rewrite of [posts](../posts). CMS blog with admin, pages, file uploads,
4 +
markdown rendering, RSS, zip import/export.
5 +
6 +
## Notes vs Rust version
7 +
8 +
- **R2/S3 storage dropped.** Local filesystem only (`UPLOADS_DIR`, default
9 +
  `uploads`). The `files.storage_backend` column stays for schema parity but
10 +
  is always `"local"`.
11 +
- Markdown: `github.com/yuin/goldmark` with GFM + Footnotes (replaces
12 +
  pulldown-cmark).
13 +
- Zip via stdlib `archive/zip`. Upload limit 10 MB; import zip limit 50 MB.
14 +
- API: `GET /api/posts` and `GET /api/posts/{slug}` (permissive CORS).
15 +
16 +
See `.env.example`.
apps/posts-go/app.go (added) +320 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
	"strings"
9 +
10 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
11 +
)
12 +
13 +
//go:embed templates/*.html static/*
14 +
var appFS embed.FS
15 +
16 +
type App struct {
17 +
	DB           *sql.DB
18 +
	Log          *slog.Logger
19 +
	Templates    *template.Template
20 +
	Sessions     *auth.Store
21 +
	AppPassword  string
22 +
	CookieSecure bool
23 +
	UploadsDir   string
24 +
	SiteURL      string
25 +
}
26 +
27 +
type Post struct {
28 +
	ID              int64
29 +
	ShortID         string
30 +
	Title           *string
31 +
	Slug            string
32 +
	Alias           *string
33 +
	CanonicalURL    *string
34 +
	PublishedDate   *string
35 +
	MetaDescription *string
36 +
	MetaImage       *string
37 +
	Lang            string
38 +
	Tags            *string
39 +
	Content         string
40 +
	Status          string
41 +
	CreatedAt       string
42 +
	UpdatedAt       string
43 +
}
44 +
45 +
func (p *Post) DisplayTitle() string {
46 +
	if p.Title != nil {
47 +
		if t := strings.TrimSpace(*p.Title); t != "" {
48 +
			return t
49 +
		}
50 +
	}
51 +
	body := strings.ReplaceAll(strings.ReplaceAll(p.Content, "\n", ""), "\r", "")
52 +
	runes := []rune(body)
53 +
	n := 25
54 +
	if len(runes) < n {
55 +
		n = len(runes)
56 +
	}
57 +
	snip := strings.TrimSpace(string(runes[:n]))
58 +
	if snip == "" {
59 +
		return "Untitled"
60 +
	}
61 +
	if len([]rune(p.Content)) > 60 {
62 +
		return snip + "…"
63 +
	}
64 +
	return snip
65 +
}
66 +
67 +
func (p *Post) TitleStr() string {
68 +
	if p.Title != nil {
69 +
		return *p.Title
70 +
	}
71 +
	return ""
72 +
}
73 +
74 +
func (p *Post) HasTitle() bool {
75 +
	return p.Title != nil && strings.TrimSpace(*p.Title) != ""
76 +
}
77 +
78 +
func (p *Post) PublishedDateStr() string {
79 +
	if p.PublishedDate != nil {
80 +
		return *p.PublishedDate
81 +
	}
82 +
	return ""
83 +
}
84 +
85 +
func (p *Post) AliasStr() string {
86 +
	if p.Alias != nil {
87 +
		return *p.Alias
88 +
	}
89 +
	return ""
90 +
}
91 +
92 +
func (p *Post) MetaDescriptionStr() string {
93 +
	if p.MetaDescription != nil {
94 +
		return *p.MetaDescription
95 +
	}
96 +
	return ""
97 +
}
98 +
99 +
func (p *Post) MetaImageStr() string {
100 +
	if p.MetaImage != nil {
101 +
		return *p.MetaImage
102 +
	}
103 +
	return ""
104 +
}
105 +
106 +
func (p *Post) CanonicalURLStr() string {
107 +
	if p.CanonicalURL != nil {
108 +
		return *p.CanonicalURL
109 +
	}
110 +
	return ""
111 +
}
112 +
113 +
func (p *Post) TagsStr() string {
114 +
	if p.Tags != nil {
115 +
		return *p.Tags
116 +
	}
117 +
	return ""
118 +
}
119 +
120 +
func (p *Post) TagList() []string {
121 +
	if p.Tags == nil {
122 +
		return nil
123 +
	}
124 +
	var out []string
125 +
	for _, t := range strings.Split(*p.Tags, ",") {
126 +
		if v := strings.TrimSpace(t); v != "" {
127 +
			out = append(out, v)
128 +
		}
129 +
	}
130 +
	return out
131 +
}
132 +
133 +
type Page struct {
134 +
	ID          int64
135 +
	ShortID     string
136 +
	Title       string
137 +
	Slug        string
138 +
	Content     string
139 +
	IsPublished bool
140 +
	NavOrder    int64
141 +
	CreatedAt   string
142 +
	UpdatedAt   string
143 +
}
144 +
145 +
type UploadedFile struct {
146 +
	ID             int64
147 +
	ShortID        string
148 +
	Filename       string
149 +
	OriginalName   string
150 +
	ContentType    string
151 +
	Size           int64
152 +
	CreatedAt      string
153 +
	StorageBackend string
154 +
}
155 +
156 +
func (f UploadedFile) SizeHuman() string {
157 +
	return humanSize(f.Size)
158 +
}
159 +
160 +
func (f UploadedFile) IsImage() bool {
161 +
	return strings.HasPrefix(f.ContentType, "image/")
162 +
}
163 +
164 +
func humanSize(n int64) string {
165 +
	const k = 1024
166 +
	if n < k {
167 +
		return formatInt(n) + " B"
168 +
	}
169 +
	v := float64(n) / float64(k)
170 +
	if v < k {
171 +
		return formatFloat1(v) + " KB"
172 +
	}
173 +
	v /= k
174 +
	if v < k {
175 +
		return formatFloat1(v) + " MB"
176 +
	}
177 +
	v /= k
178 +
	return formatFloat1(v) + " GB"
179 +
}
180 +
181 +
func formatInt(n int64) string {
182 +
	if n == 0 {
183 +
		return "0"
184 +
	}
185 +
	neg := n < 0
186 +
	if neg {
187 +
		n = -n
188 +
	}
189 +
	buf := [24]byte{}
190 +
	i := len(buf)
191 +
	for n > 0 {
192 +
		i--
193 +
		buf[i] = byte('0' + n%10)
194 +
		n /= 10
195 +
	}
196 +
	if neg {
197 +
		i--
198 +
		buf[i] = '-'
199 +
	}
200 +
	return string(buf[i:])
201 +
}
202 +
203 +
func formatFloat1(v float64) string {
204 +
	scaled := int64(v*10 + 0.5)
205 +
	whole := scaled / 10
206 +
	frac := scaled % 10
207 +
	return formatInt(whole) + "." + string(byte('0'+frac))
208 +
}
209 +
210 +
type NavLink struct {
211 +
	Label string
212 +
	URL   string
213 +
}
214 +
215 +
type siteContext struct {
216 +
	BlogTitle   string
217 +
	NavLinks    []NavLink
218 +
	FaviconURL  string
219 +
	OGImageURL  string
220 +
	SiteURL     string
221 +
	HeaderHTML  template.HTML
222 +
	FooterHTML  template.HTML
223 +
}
224 +
225 +
type loginPageData struct {
226 +
	Error string
227 +
}
228 +
229 +
type indexPageData struct {
230 +
	BlogTitle       string
231 +
	BlogDescription string
232 +
	IntroHTML       template.HTML
233 +
	Posts           []Post
234 +
	NavLinks        []NavLink
235 +
	FaviconURL      string
236 +
	OGImageURL      string
237 +
	SiteURL         string
238 +
	HeaderHTML      template.HTML
239 +
	FooterHTML      template.HTML
240 +
}
241 +
242 +
type postPageData struct {
243 +
	BlogTitle       string
244 +
	NavLinks        []NavLink
245 +
	Post            Post
246 +
	RenderedContent template.HTML
247 +
	FaviconURL      string
248 +
	OGImageURL      string
249 +
	SiteURL         string
250 +
	HeaderHTML      template.HTML
251 +
	FooterHTML      template.HTML
252 +
}
253 +
254 +
type pagePageData struct {
255 +
	BlogTitle       string
256 +
	NavLinks        []NavLink
257 +
	Page            Page
258 +
	RenderedContent template.HTML
259 +
	FaviconURL      string
260 +
	OGImageURL      string
261 +
	SiteURL         string
262 +
	HeaderHTML      template.HTML
263 +
	FooterHTML      template.HTML
264 +
}
265 +
266 +
type postsListPageData struct {
267 +
	BlogTitle  string
268 +
	NavLinks   []NavLink
269 +
	Posts      []Post
270 +
	FaviconURL string
271 +
	OGImageURL string
272 +
	SiteURL    string
273 +
	HeaderHTML template.HTML
274 +
	FooterHTML template.HTML
275 +
}
276 +
277 +
type adminIndexPageData struct {
278 +
	Posts []Post
279 +
}
280 +
281 +
type adminPostFormPageData struct {
282 +
	Post  *Post
283 +
	Error string
284 +
}
285 +
286 +
type adminPagesPageData struct {
287 +
	Pages []Page
288 +
}
289 +
290 +
type adminPageFormPageData struct {
291 +
	Page  *Page
292 +
	Error string
293 +
}
294 +
295 +
type adminSettingsPageData struct {
296 +
	BlogTitle       string
297 +
	BlogDescription string
298 +
	IntroContent    string
299 +
	NavLinksRaw     string
300 +
	CustomCSS       string
301 +
	DefaultCSS      string
302 +
	FaviconURL      string
303 +
	OGImageURL      string
304 +
	CustomHeader    string
305 +
	CustomFooter    string
306 +
	Success         bool
307 +
}
308 +
309 +
type adminFilesPageData struct {
310 +
	Files   []UploadedFile
311 +
	SiteURL string
312 +
	Error   string
313 +
	Success bool
314 +
}
315 +
316 +
type adminImportPageData struct {
317 +
	Error    string
318 +
	Imported *int
319 +
	Skipped  *int
320 +
}
apps/posts-go/db.go (added) +453 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
10 +
	_ "modernc.org/sqlite"
11 +
)
12 +
13 +
const postsSchema = `
14 +
CREATE TABLE IF NOT EXISTS posts (
15 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
16 +
    short_id        TEXT NOT NULL UNIQUE,
17 +
    title           TEXT,
18 +
    slug            TEXT NOT NULL UNIQUE,
19 +
    alias           TEXT,
20 +
    canonical_url   TEXT,
21 +
    published_date  TEXT,
22 +
    meta_description TEXT,
23 +
    meta_image      TEXT,
24 +
    lang            TEXT NOT NULL DEFAULT 'en',
25 +
    tags            TEXT,
26 +
    content         TEXT NOT NULL,
27 +
    status          TEXT NOT NULL DEFAULT 'draft',
28 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
29 +
    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
30 +
);
31 +
32 +
CREATE TABLE IF NOT EXISTS pages (
33 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
34 +
    short_id        TEXT NOT NULL UNIQUE,
35 +
    title           TEXT NOT NULL,
36 +
    slug            TEXT NOT NULL UNIQUE,
37 +
    content         TEXT NOT NULL,
38 +
    is_published    INTEGER NOT NULL DEFAULT 0,
39 +
    nav_order       INTEGER NOT NULL DEFAULT 0,
40 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
41 +
    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
42 +
);
43 +
44 +
CREATE TABLE IF NOT EXISTS settings (
45 +
    key   TEXT PRIMARY KEY,
46 +
    value TEXT NOT NULL
47 +
);
48 +
49 +
CREATE TABLE IF NOT EXISTS files (
50 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
51 +
    short_id        TEXT NOT NULL UNIQUE,
52 +
    filename        TEXT NOT NULL UNIQUE,
53 +
    original_name   TEXT NOT NULL,
54 +
    content_type    TEXT NOT NULL DEFAULT 'application/octet-stream',
55 +
    size            INTEGER NOT NULL,
56 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
57 +
    storage_backend TEXT NOT NULL DEFAULT 'local'
58 +
);
59 +
`
60 +
61 +
var defaultSettings = [][2]string{
62 +
	{"blog_title", "My Blog"},
63 +
	{"blog_description", "A simple blog"},
64 +
	{"intro_content", ""},
65 +
	{"nav_links", "[blog](/) [posts](/posts)"},
66 +
	{"custom_css", ""},
67 +
	{"favicon_url", ""},
68 +
	{"og_image_url", ""},
69 +
	{"custom_header", ""},
70 +
	{"custom_footer", `<div>
71 +
<a href="/feed.xml" class="rss-link" title="RSS Feed"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path fill="currentColor" d="M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8"/></svg></a>
72 +
</div>`},
73 +
}
74 +
75 +
func openDB(path string) (*sql.DB, error) {
76 +
	db, err := sql.Open("sqlite", path)
77 +
	if err != nil {
78 +
		return nil, err
79 +
	}
80 +
	db.SetMaxOpenConns(1)
81 +
	db.SetMaxIdleConns(1)
82 +
	if _, err := db.Exec(postsSchema); err != nil {
83 +
		return nil, err
84 +
	}
85 +
	for _, kv := range defaultSettings {
86 +
		_, _ = db.Exec(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, kv[0], kv[1])
87 +
	}
88 +
	return db, nil
89 +
}
90 +
91 +
const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at`
92 +
93 +
func scanPost(s interface{ Scan(...any) error }) (*Post, error) {
94 +
	var p Post
95 +
	var title, alias, canonicalURL, publishedDate, metaDesc, metaImage, tags sql.NullString
96 +
	err := s.Scan(&p.ID, &p.ShortID, &title, &p.Slug, &alias, &canonicalURL,
97 +
		&publishedDate, &metaDesc, &metaImage, &p.Lang, &tags, &p.Content,
98 +
		&p.Status, &p.CreatedAt, &p.UpdatedAt)
99 +
	if errors.Is(err, sql.ErrNoRows) {
100 +
		return nil, nil
101 +
	}
102 +
	if err != nil {
103 +
		return nil, err
104 +
	}
105 +
	if title.Valid {
106 +
		v := title.String
107 +
		p.Title = &v
108 +
	}
109 +
	if alias.Valid {
110 +
		v := alias.String
111 +
		p.Alias = &v
112 +
	}
113 +
	if canonicalURL.Valid {
114 +
		v := canonicalURL.String
115 +
		p.CanonicalURL = &v
116 +
	}
117 +
	if publishedDate.Valid {
118 +
		v := publishedDate.String
119 +
		p.PublishedDate = &v
120 +
	}
121 +
	if metaDesc.Valid {
122 +
		v := metaDesc.String
123 +
		p.MetaDescription = &v
124 +
	}
125 +
	if metaImage.Valid {
126 +
		v := metaImage.String
127 +
		p.MetaImage = &v
128 +
	}
129 +
	if tags.Valid {
130 +
		v := tags.String
131 +
		p.Tags = &v
132 +
	}
133 +
	return &p, nil
134 +
}
135 +
136 +
type PostInput struct {
137 +
	Title           *string
138 +
	Slug            string
139 +
	Content         string
140 +
	Status          string
141 +
	Alias           *string
142 +
	CanonicalURL    *string
143 +
	PublishedDate   *string
144 +
	MetaDescription *string
145 +
	MetaImage       *string
146 +
	Lang            string
147 +
	Tags            *string
148 +
}
149 +
150 +
func nullable(p *string) any {
151 +
	if p == nil {
152 +
		return nil
153 +
	}
154 +
	return *p
155 +
}
156 +
157 +
func createPost(db *sql.DB, in PostInput) (*Post, error) {
158 +
	shortID, err := auth.GenerateShortID(10)
159 +
	if err != nil {
160 +
		return nil, err
161 +
	}
162 +
	res, err := db.Exec(
163 +
		`INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags)
164 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
165 +
		shortID, nullable(in.Title), in.Slug, in.Content, in.Status,
166 +
		nullable(in.Alias), nullable(in.CanonicalURL), nullable(in.PublishedDate),
167 +
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags),
168 +
	)
169 +
	if err != nil {
170 +
		return nil, err
171 +
	}
172 +
	id, _ := res.LastInsertId()
173 +
	return scanPost(db.QueryRow(`SELECT `+postCols+` FROM posts WHERE id = ?`, id))
174 +
}
175 +
176 +
func getPostByShortID(db *sql.DB, shortID string) (*Post, error) {
177 +
	return scanPost(db.QueryRow(`SELECT `+postCols+` FROM posts WHERE short_id = ?`, shortID))
178 +
}
179 +
180 +
func getPostBySlug(db *sql.DB, slug string) (*Post, error) {
181 +
	return scanPost(db.QueryRow(`SELECT `+postCols+` FROM posts WHERE slug = ?`, slug))
182 +
}
183 +
184 +
func getAllPosts(db *sql.DB) ([]Post, error) {
185 +
	rows, err := db.Query(`SELECT ` + postCols + ` FROM posts ORDER BY id DESC`)
186 +
	if err != nil {
187 +
		return nil, err
188 +
	}
189 +
	defer rows.Close()
190 +
	var out []Post
191 +
	for rows.Next() {
192 +
		p, err := scanPost(rows)
193 +
		if err != nil {
194 +
			return nil, err
195 +
		}
196 +
		out = append(out, *p)
197 +
	}
198 +
	return out, rows.Err()
199 +
}
200 +
201 +
func getPublishedPosts(db *sql.DB, limit int64) ([]Post, error) {
202 +
	if limit <= 0 {
203 +
		limit = -1
204 +
	}
205 +
	rows, err := db.Query(
206 +
		`SELECT `+postCols+` FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC LIMIT ?`, limit)
207 +
	if err != nil {
208 +
		return nil, err
209 +
	}
210 +
	defer rows.Close()
211 +
	var out []Post
212 +
	for rows.Next() {
213 +
		p, err := scanPost(rows)
214 +
		if err != nil {
215 +
			return nil, err
216 +
		}
217 +
		out = append(out, *p)
218 +
	}
219 +
	return out, rows.Err()
220 +
}
221 +
222 +
func updatePost(db *sql.DB, shortID string, in PostInput) (*Post, error) {
223 +
	res, err := db.Exec(
224 +
		`UPDATE posts SET title = ?, slug = ?, content = ?, status = ?, alias = ?, canonical_url = ?,
225 +
		 published_date = CASE WHEN ? = 'published' THEN COALESCE(?, published_date, datetime('now')) ELSE ? END,
226 +
		 meta_description = ?, meta_image = ?, lang = ?, tags = ?,
227 +
		 updated_at = datetime('now') WHERE short_id = ?`,
228 +
		nullable(in.Title), in.Slug, in.Content, in.Status, nullable(in.Alias), nullable(in.CanonicalURL),
229 +
		in.Status, nullable(in.PublishedDate), nullable(in.PublishedDate),
230 +
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), shortID,
231 +
	)
232 +
	if err != nil {
233 +
		return nil, err
234 +
	}
235 +
	if n, _ := res.RowsAffected(); n == 0 {
236 +
		return nil, nil
237 +
	}
238 +
	return getPostByShortID(db, shortID)
239 +
}
240 +
241 +
func deletePost(db *sql.DB, shortID string) (bool, error) {
242 +
	res, err := db.Exec(`DELETE FROM posts WHERE short_id = ?`, shortID)
243 +
	if err != nil {
244 +
		return false, err
245 +
	}
246 +
	n, _ := res.RowsAffected()
247 +
	return n > 0, nil
248 +
}
249 +
250 +
func togglePostStatus(db *sql.DB, shortID string) (string, error) {
251 +
	var current string
252 +
	err := db.QueryRow(`SELECT status FROM posts WHERE short_id = ?`, shortID).Scan(&current)
253 +
	if errors.Is(err, sql.ErrNoRows) {
254 +
		return "", nil
255 +
	}
256 +
	if err != nil {
257 +
		return "", err
258 +
	}
259 +
	newStatus := "published"
260 +
	if current == "published" {
261 +
		newStatus = "draft"
262 +
	}
263 +
	if newStatus == "published" {
264 +
		_, err = db.Exec(
265 +
			`UPDATE posts SET status = ?, published_date = COALESCE(published_date, datetime('now')), updated_at = datetime('now') WHERE short_id = ?`,
266 +
			newStatus, shortID)
267 +
	} else {
268 +
		_, err = db.Exec(`UPDATE posts SET status = ?, updated_at = datetime('now') WHERE short_id = ?`,
269 +
			newStatus, shortID)
270 +
	}
271 +
	return newStatus, err
272 +
}
273 +
274 +
func findAliasRedirect(db *sql.DB, alias string) (string, error) {
275 +
	var slug string
276 +
	err := db.QueryRow(`SELECT slug FROM posts WHERE alias = ? AND status = 'published'`, alias).Scan(&slug)
277 +
	if errors.Is(err, sql.ErrNoRows) {
278 +
		return "", nil
279 +
	}
280 +
	if err != nil {
281 +
		return "", err
282 +
	}
283 +
	return "/posts/" + slug, nil
284 +
}
285 +
286 +
const pageCols = `id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at`
287 +
288 +
func scanPage(s interface{ Scan(...any) error }) (*Page, error) {
289 +
	var p Page
290 +
	var pub int
291 +
	err := s.Scan(&p.ID, &p.ShortID, &p.Title, &p.Slug, &p.Content, &pub, &p.NavOrder, &p.CreatedAt, &p.UpdatedAt)
292 +
	if errors.Is(err, sql.ErrNoRows) {
293 +
		return nil, nil
294 +
	}
295 +
	if err != nil {
296 +
		return nil, err
297 +
	}
298 +
	p.IsPublished = pub != 0
299 +
	return &p, nil
300 +
}
301 +
302 +
func createPage(db *sql.DB, title, slug, content string, isPublished bool, navOrder int64) (*Page, error) {
303 +
	shortID, err := auth.GenerateShortID(10)
304 +
	if err != nil {
305 +
		return nil, err
306 +
	}
307 +
	pub := 0
308 +
	if isPublished {
309 +
		pub = 1
310 +
	}
311 +
	res, err := db.Exec(
312 +
		`INSERT INTO pages (short_id, title, slug, content, is_published, nav_order) VALUES (?, ?, ?, ?, ?, ?)`,
313 +
		shortID, title, slug, content, pub, navOrder)
314 +
	if err != nil {
315 +
		return nil, err
316 +
	}
317 +
	id, _ := res.LastInsertId()
318 +
	return scanPage(db.QueryRow(`SELECT `+pageCols+` FROM pages WHERE id = ?`, id))
319 +
}
320 +
321 +
func getPageByShortID(db *sql.DB, shortID string) (*Page, error) {
322 +
	return scanPage(db.QueryRow(`SELECT `+pageCols+` FROM pages WHERE short_id = ?`, shortID))
323 +
}
324 +
325 +
func getPageBySlug(db *sql.DB, slug string) (*Page, error) {
326 +
	return scanPage(db.QueryRow(`SELECT `+pageCols+` FROM pages WHERE slug = ?`, slug))
327 +
}
328 +
329 +
func getAllPages(db *sql.DB) ([]Page, error) {
330 +
	rows, err := db.Query(`SELECT ` + pageCols + ` FROM pages ORDER BY nav_order ASC, id ASC`)
331 +
	if err != nil {
332 +
		return nil, err
333 +
	}
334 +
	defer rows.Close()
335 +
	var out []Page
336 +
	for rows.Next() {
337 +
		p, err := scanPage(rows)
338 +
		if err != nil {
339 +
			return nil, err
340 +
		}
341 +
		out = append(out, *p)
342 +
	}
343 +
	return out, rows.Err()
344 +
}
345 +
346 +
func updatePage(db *sql.DB, shortID, title, slug, content string, isPublished bool, navOrder int64) (*Page, error) {
347 +
	pub := 0
348 +
	if isPublished {
349 +
		pub = 1
350 +
	}
351 +
	res, err := db.Exec(
352 +
		`UPDATE pages SET title = ?, slug = ?, content = ?, is_published = ?, nav_order = ?, updated_at = datetime('now') WHERE short_id = ?`,
353 +
		title, slug, content, pub, navOrder, shortID)
354 +
	if err != nil {
355 +
		return nil, err
356 +
	}
357 +
	if n, _ := res.RowsAffected(); n == 0 {
358 +
		return nil, nil
359 +
	}
360 +
	return getPageByShortID(db, shortID)
361 +
}
362 +
363 +
func deletePage(db *sql.DB, shortID string) error {
364 +
	_, err := db.Exec(`DELETE FROM pages WHERE short_id = ?`, shortID)
365 +
	return err
366 +
}
367 +
368 +
func getSetting(db *sql.DB, key string) (string, error) {
369 +
	var v string
370 +
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
371 +
	if errors.Is(err, sql.ErrNoRows) {
372 +
		return "", nil
373 +
	}
374 +
	if err != nil {
375 +
		return "", err
376 +
	}
377 +
	return v, nil
378 +
}
379 +
380 +
func setSetting(db *sql.DB, key, value string) error {
381 +
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
382 +
		key, value)
383 +
	return err
384 +
}
385 +
386 +
const fileCols = `id, short_id, filename, original_name, content_type, size, created_at, storage_backend`
387 +
388 +
func scanFile(s interface{ Scan(...any) error }) (*UploadedFile, error) {
389 +
	var f UploadedFile
390 +
	err := s.Scan(&f.ID, &f.ShortID, &f.Filename, &f.OriginalName, &f.ContentType, &f.Size, &f.CreatedAt, &f.StorageBackend)
391 +
	if errors.Is(err, sql.ErrNoRows) {
392 +
		return nil, nil
393 +
	}
394 +
	if err != nil {
395 +
		return nil, err
396 +
	}
397 +
	return &f, nil
398 +
}
399 +
400 +
func createFile(db *sql.DB, filename, originalName, contentType string, size int64) (*UploadedFile, error) {
401 +
	shortID, err := auth.GenerateShortID(10)
402 +
	if err != nil {
403 +
		return nil, err
404 +
	}
405 +
	res, err := db.Exec(
406 +
		`INSERT INTO files (short_id, filename, original_name, content_type, size, storage_backend) VALUES (?, ?, ?, ?, ?, 'local')`,
407 +
		shortID, filename, originalName, contentType, size)
408 +
	if err != nil {
409 +
		return nil, err
410 +
	}
411 +
	id, _ := res.LastInsertId()
412 +
	return scanFile(db.QueryRow(`SELECT `+fileCols+` FROM files WHERE id = ?`, id))
413 +
}
414 +
415 +
func getFileByFilename(db *sql.DB, filename string) (*UploadedFile, error) {
416 +
	return scanFile(db.QueryRow(`SELECT `+fileCols+` FROM files WHERE filename = ?`, filename))
417 +
}
418 +
419 +
func getAllFiles(db *sql.DB) ([]UploadedFile, error) {
420 +
	rows, err := db.Query(`SELECT ` + fileCols + ` FROM files ORDER BY id DESC`)
421 +
	if err != nil {
422 +
		return nil, err
423 +
	}
424 +
	defer rows.Close()
425 +
	var out []UploadedFile
426 +
	for rows.Next() {
427 +
		f, err := scanFile(rows)
428 +
		if err != nil {
429 +
			return nil, err
430 +
		}
431 +
		out = append(out, *f)
432 +
	}
433 +
	return out, rows.Err()
434 +
}
435 +
436 +
func deleteFile(db *sql.DB, shortID string) (*UploadedFile, error) {
437 +
	f, err := scanFile(db.QueryRow(`SELECT `+fileCols+` FROM files WHERE short_id = ?`, shortID))
438 +
	if err != nil || f == nil {
439 +
		return f, err
440 +
	}
441 +
	if _, err := db.Exec(`DELETE FROM files WHERE short_id = ?`, shortID); err != nil {
442 +
		return nil, err
443 +
	}
444 +
	return f, nil
445 +
}
446 +
447 +
func nowDatetime() string {
448 +
	return time.Now().UTC().Format("2006-01-02 15:04:05")
449 +
}
450 +
451 +
func _useStrings() {
452 +
	_ = strings.TrimSpace
453 +
}
apps/posts-go/docker-compose.yml (added) +21 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/posts-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - POSTS_DB_PATH=/data/posts-go.sqlite
12 +
      - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme}
13 +
      - UPLOADS_DIR=/data/uploads
14 +
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
15 +
      - SITE_URL=${SITE_URL:-http://localhost:3000}
16 +
    volumes:
17 +
      - posts-go-data:/data
18 +
    restart: unless-stopped
19 +
20 +
volumes:
21 +
  posts-go-data:
apps/posts-go/go.mod (added) +33 −0
1 +
module github.com/stevedylandev/andromeda/apps/posts-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 +
	github.com/yuin/goldmark v1.7.8
11 +
	modernc.org/sqlite v1.37.1
12 +
)
13 +
14 +
require (
15 +
	github.com/dustin/go-humanize v1.0.1 // indirect
16 +
	github.com/google/uuid v1.6.0 // indirect
17 +
	github.com/mattn/go-isatty v0.0.20 // indirect
18 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
19 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
20 +
	golang.org/x/crypto v0.39.0 // indirect
21 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
22 +
	golang.org/x/sys v0.33.0 // indirect
23 +
	modernc.org/libc v1.65.7 // indirect
24 +
	modernc.org/mathutil v1.7.1 // indirect
25 +
	modernc.org/memory v1.11.0 // indirect
26 +
)
27 +
28 +
replace (
29 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
30 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
31 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
32 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
33 +
)
apps/posts-go/go.sum (added) +51 −0
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 +
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
14 +
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
15 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
16 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
17 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
18 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
19 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
20 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
21 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
22 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
23 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
24 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
25 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
26 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
27 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
28 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
29 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
30 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
31 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
32 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
33 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
34 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
35 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
36 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
37 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
38 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
39 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
40 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
41 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
42 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
43 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
44 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
45 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
46 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
47 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
48 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
49 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
50 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
51 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/posts-go/handlers_admin.go (added) +551 −0
1 +
package main
2 +
3 +
import (
4 +
	"archive/zip"
5 +
	"bytes"
6 +
	"fmt"
7 +
	"io"
8 +
	"net/http"
9 +
	"net/url"
10 +
	"strings"
11 +
12 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
13 +
	"github.com/stevedylandev/andromeda/crates-go/web"
14 +
)
15 +
16 +
const importMaxBytes = 50 * 1024 * 1024
17 +
const uploadMaxBytes = 10 * 1024 * 1024
18 +
const bodyLimit = 51 * 1024 * 1024
19 +
20 +
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
21 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
22 +
}
23 +
24 +
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
25 +
	if err := r.ParseForm(); err != nil {
26 +
		http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther)
27 +
		return
28 +
	}
29 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AppPassword) {
30 +
		http.Redirect(w, r, "/admin/login?error=Invalid+password", http.StatusSeeOther)
31 +
		return
32 +
	}
33 +
	token, err := a.Sessions.Create()
34 +
	if err != nil {
35 +
		a.Log.Error("create session", "err", err)
36 +
		http.Redirect(w, r, "/admin/login?error=Server+error", http.StatusSeeOther)
37 +
		return
38 +
	}
39 +
	a.Sessions.PruneExpired()
40 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
41 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
42 +
}
43 +
44 +
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
45 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
46 +
		a.Sessions.Delete(c.Value)
47 +
	}
48 +
	http.SetCookie(w, a.Sessions.ClearCookie())
49 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
50 +
}
51 +
52 +
func (a *App) adminIndex(w http.ResponseWriter, r *http.Request) {
53 +
	posts, err := getAllPosts(a.DB)
54 +
	if err != nil {
55 +
		http.Error(w, "Server error", http.StatusInternalServerError)
56 +
		return
57 +
	}
58 +
	web.Render(a.Templates, w, "admin_index.html", adminIndexPageData{Posts: posts}, a.Log)
59 +
}
60 +
61 +
func (a *App) adminNewPost(w http.ResponseWriter, r *http.Request) {
62 +
	web.Render(a.Templates, w, "admin_post_form.html", adminPostFormPageData{Error: r.URL.Query().Get("error")}, a.Log)
63 +
}
64 +
65 +
func (a *App) adminCreatePost(w http.ResponseWriter, r *http.Request) {
66 +
	if err := r.ParseForm(); err != nil {
67 +
		http.Redirect(w, r, "/admin/posts/new?error=Bad+request", http.StatusSeeOther)
68 +
		return
69 +
	}
70 +
	attrs := parseAttributes(r.FormValue("attributes"))
71 +
	title := strings.TrimSpace(attrs.Title)
72 +
	slug := deriveSlugWith(a, title, strings.TrimSpace(attrs.Slug))
73 +
	status := "draft"
74 +
	if r.FormValue("action") == "publish" {
75 +
		status = "published"
76 +
	}
77 +
	lang := "en"
78 +
	if l := strings.TrimSpace(attrs.Lang); l != "" {
79 +
		lang = l
80 +
	}
81 +
	pub := strings.TrimSpace(attrs.PublishedDate)
82 +
	if pub == "" {
83 +
		pub = nowDatetime()
84 +
	}
85 +
	in := PostInput{
86 +
		Title: optStr(title), Slug: slug, Content: r.FormValue("content"),
87 +
		Status: status, Alias: optStr(attrs.Alias),
88 +
		PublishedDate:   &pub,
89 +
		MetaDescription: optStr(attrs.MetaDescription),
90 +
		MetaImage:       optStr(attrs.MetaImage),
91 +
		Lang:            lang, Tags: optStr(attrs.Tags),
92 +
	}
93 +
	if _, err := createPost(a.DB, in); err != nil {
94 +
		a.Log.Error("create post", "err", err)
95 +
		http.Redirect(w, r, "/admin/posts/new?error=Failed+to+create+post", http.StatusSeeOther)
96 +
		return
97 +
	}
98 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
99 +
}
100 +
101 +
func deriveSlugWith(a *App, title, slug string) string {
102 +
	if slug != "" {
103 +
		return slug
104 +
	}
105 +
	if s := slugify(title); s != "" {
106 +
		return s
107 +
	}
108 +
	id, _ := auth.GenerateShortID(10)
109 +
	return id
110 +
}
111 +
112 +
func (a *App) adminEditPost(w http.ResponseWriter, r *http.Request) {
113 +
	shortID := r.PathValue("id")
114 +
	post, err := getPostByShortID(a.DB, shortID)
115 +
	if err != nil {
116 +
		http.Error(w, "Server error", http.StatusInternalServerError)
117 +
		return
118 +
	}
119 +
	if post == nil {
120 +
		http.Error(w, "Post not found", http.StatusNotFound)
121 +
		return
122 +
	}
123 +
	web.Render(a.Templates, w, "admin_post_form.html", adminPostFormPageData{Post: post, Error: r.URL.Query().Get("error")}, a.Log)
124 +
}
125 +
126 +
func (a *App) adminUpdatePost(w http.ResponseWriter, r *http.Request) {
127 +
	shortID := r.PathValue("id")
128 +
	if err := r.ParseForm(); err != nil {
129 +
		http.Redirect(w, r, "/admin/posts/"+shortID+"/edit?error=Bad+request", http.StatusSeeOther)
130 +
		return
131 +
	}
132 +
	attrs := parseAttributes(r.FormValue("attributes"))
133 +
	title := strings.TrimSpace(attrs.Title)
134 +
	slug := deriveSlugWith(a, title, strings.TrimSpace(attrs.Slug))
135 +
	status := "draft"
136 +
	if r.FormValue("action") == "publish" {
137 +
		status = "published"
138 +
	}
139 +
	lang := "en"
140 +
	if l := strings.TrimSpace(attrs.Lang); l != "" {
141 +
		lang = l
142 +
	}
143 +
	var pubDate *string
144 +
	if t := strings.TrimSpace(attrs.PublishedDate); t != "" {
145 +
		pubDate = &t
146 +
	}
147 +
	in := PostInput{
148 +
		Title: optStr(title), Slug: slug, Content: r.FormValue("content"),
149 +
		Status: status, Alias: optStr(attrs.Alias),
150 +
		PublishedDate:   pubDate,
151 +
		MetaDescription: optStr(attrs.MetaDescription),
152 +
		MetaImage:       optStr(attrs.MetaImage),
153 +
		Lang:            lang, Tags: optStr(attrs.Tags),
154 +
	}
155 +
	if _, err := updatePost(a.DB, shortID, in); err != nil {
156 +
		a.Log.Error("update post", "err", err)
157 +
		http.Redirect(w, r, "/admin/posts/"+shortID+"/edit?error=Failed+to+update", http.StatusSeeOther)
158 +
		return
159 +
	}
160 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
161 +
}
162 +
163 +
func (a *App) adminDeletePost(w http.ResponseWriter, r *http.Request) {
164 +
	_, _ = deletePost(a.DB, r.PathValue("id"))
165 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
166 +
}
167 +
168 +
func (a *App) adminTogglePublish(w http.ResponseWriter, r *http.Request) {
169 +
	_, _ = togglePostStatus(a.DB, r.PathValue("id"))
170 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
171 +
}
172 +
173 +
func (a *App) adminPages(w http.ResponseWriter, r *http.Request) {
174 +
	pages, err := getAllPages(a.DB)
175 +
	if err != nil {
176 +
		http.Error(w, "Server error", http.StatusInternalServerError)
177 +
		return
178 +
	}
179 +
	web.Render(a.Templates, w, "admin_pages.html", adminPagesPageData{Pages: pages}, a.Log)
180 +
}
181 +
182 +
func (a *App) adminNewPage(w http.ResponseWriter, r *http.Request) {
183 +
	web.Render(a.Templates, w, "admin_page_form.html", adminPageFormPageData{Error: r.URL.Query().Get("error")}, a.Log)
184 +
}
185 +
186 +
func (a *App) adminCreatePage(w http.ResponseWriter, r *http.Request) {
187 +
	if err := r.ParseForm(); err != nil {
188 +
		http.Redirect(w, r, "/admin/pages/new?error=Bad+request", http.StatusSeeOther)
189 +
		return
190 +
	}
191 +
	attrs := parsePageAttributes(r.FormValue("attributes"))
192 +
	title := strings.TrimSpace(attrs.Title)
193 +
	slug := strings.TrimSpace(attrs.Slug)
194 +
	if title == "" || slug == "" {
195 +
		http.Redirect(w, r, "/admin/pages/new?error=Title+and+slug+are+required", http.StatusSeeOther)
196 +
		return
197 +
	}
198 +
	if isReservedPageSlug(slug) {
199 +
		http.Redirect(w, r, "/admin/pages/new?error=That+slug+is+reserved", http.StatusSeeOther)
200 +
		return
201 +
	}
202 +
	if _, err := createPage(a.DB, title, slug, r.FormValue("content"), attrs.IsPublished, 0); err != nil {
203 +
		a.Log.Error("create page", "err", err)
204 +
		http.Redirect(w, r, "/admin/pages/new?error=Failed+to+create+page", http.StatusSeeOther)
205 +
		return
206 +
	}
207 +
	http.Redirect(w, r, "/admin/pages", http.StatusSeeOther)
208 +
}
209 +
210 +
func (a *App) adminEditPage(w http.ResponseWriter, r *http.Request) {
211 +
	page, err := getPageByShortID(a.DB, r.PathValue("id"))
212 +
	if err != nil {
213 +
		http.Error(w, "Server error", http.StatusInternalServerError)
214 +
		return
215 +
	}
216 +
	if page == nil {
217 +
		http.Error(w, "Page not found", http.StatusNotFound)
218 +
		return
219 +
	}
220 +
	web.Render(a.Templates, w, "admin_page_form.html", adminPageFormPageData{Page: page, Error: r.URL.Query().Get("error")}, a.Log)
221 +
}
222 +
223 +
func (a *App) adminUpdatePage(w http.ResponseWriter, r *http.Request) {
224 +
	shortID := r.PathValue("id")
225 +
	if err := r.ParseForm(); err != nil {
226 +
		http.Redirect(w, r, "/admin/pages/"+shortID+"/edit?error=Bad+request", http.StatusSeeOther)
227 +
		return
228 +
	}
229 +
	attrs := parsePageAttributes(r.FormValue("attributes"))
230 +
	title := strings.TrimSpace(attrs.Title)
231 +
	slug := strings.TrimSpace(attrs.Slug)
232 +
	if title == "" || slug == "" {
233 +
		http.Redirect(w, r, "/admin/pages/"+shortID+"/edit?error=Title+and+slug+are+required", http.StatusSeeOther)
234 +
		return
235 +
	}
236 +
	if isReservedPageSlug(slug) {
237 +
		http.Redirect(w, r, "/admin/pages/"+shortID+"/edit?error=That+slug+is+reserved", http.StatusSeeOther)
238 +
		return
239 +
	}
240 +
	if _, err := updatePage(a.DB, shortID, title, slug, r.FormValue("content"), attrs.IsPublished, 0); err != nil {
241 +
		a.Log.Error("update page", "err", err)
242 +
		http.Redirect(w, r, "/admin/pages/"+shortID+"/edit?error=Failed+to+update", http.StatusSeeOther)
243 +
		return
244 +
	}
245 +
	http.Redirect(w, r, "/admin/pages", http.StatusSeeOther)
246 +
}
247 +
248 +
func (a *App) adminDeletePage(w http.ResponseWriter, r *http.Request) {
249 +
	_ = deletePage(a.DB, r.PathValue("id"))
250 +
	http.Redirect(w, r, "/admin/pages", http.StatusSeeOther)
251 +
}
252 +
253 +
func (a *App) adminGetSettings(w http.ResponseWriter, r *http.Request) {
254 +
	get := func(k string) string { v, _ := getSetting(a.DB, k); return v }
255 +
	defaultCSS, _ := appFS.ReadFile("static/styles.css")
256 +
	web.Render(a.Templates, w, "admin_settings.html", adminSettingsPageData{
257 +
		BlogTitle:       get("blog_title"),
258 +
		BlogDescription: get("blog_description"),
259 +
		IntroContent:    get("intro_content"),
260 +
		NavLinksRaw:     get("nav_links"),
261 +
		CustomCSS:       get("custom_css"),
262 +
		DefaultCSS:      string(defaultCSS),
263 +
		FaviconURL:      get("favicon_url"),
264 +
		OGImageURL:      get("og_image_url"),
265 +
		CustomHeader:    get("custom_header"),
266 +
		CustomFooter:    get("custom_footer"),
267 +
		Success:         r.URL.Query().Get("success") == "true",
268 +
	}, a.Log)
269 +
}
270 +
271 +
func (a *App) adminPostSettings(w http.ResponseWriter, r *http.Request) {
272 +
	if err := r.ParseForm(); err != nil {
273 +
		http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
274 +
		return
275 +
	}
276 +
	_ = setSetting(a.DB, "blog_title", strings.TrimSpace(r.FormValue("blog_title")))
277 +
	_ = setSetting(a.DB, "blog_description", strings.TrimSpace(r.FormValue("blog_description")))
278 +
	_ = setSetting(a.DB, "intro_content", r.FormValue("intro_content"))
279 +
	_ = setSetting(a.DB, "nav_links", r.FormValue("nav_links"))
280 +
	_ = setSetting(a.DB, "custom_css", r.FormValue("custom_css"))
281 +
	_ = setSetting(a.DB, "favicon_url", strings.TrimSpace(r.FormValue("favicon_url")))
282 +
	_ = setSetting(a.DB, "og_image_url", strings.TrimSpace(r.FormValue("og_image_url")))
283 +
	_ = setSetting(a.DB, "custom_header", r.FormValue("custom_header"))
284 +
	_ = setSetting(a.DB, "custom_footer", r.FormValue("custom_footer"))
285 +
	http.Redirect(w, r, "/admin/settings?success=true", http.StatusSeeOther)
286 +
}
287 +
288 +
func (a *App) adminFiles(w http.ResponseWriter, r *http.Request) {
289 +
	files, err := getAllFiles(a.DB)
290 +
	if err != nil {
291 +
		http.Error(w, "Server error", http.StatusInternalServerError)
292 +
		return
293 +
	}
294 +
	web.Render(a.Templates, w, "admin_files.html", adminFilesPageData{
295 +
		Files: files, SiteURL: a.SiteURL,
296 +
		Error:   r.URL.Query().Get("error"),
297 +
		Success: r.URL.Query().Get("success") == "true",
298 +
	}, a.Log)
299 +
}
300 +
301 +
func (a *App) adminUploadFile(w http.ResponseWriter, r *http.Request) {
302 +
	r.Body = http.MaxBytesReader(w, r.Body, bodyLimit)
303 +
	if err := r.ParseMultipartForm(uploadMaxBytes); err != nil {
304 +
		http.Redirect(w, r, "/admin/files?error=Failed+to+read+upload", http.StatusSeeOther)
305 +
		return
306 +
	}
307 +
	file, header, err := r.FormFile("file")
308 +
	if err != nil {
309 +
		http.Redirect(w, r, "/admin/files?error=No+file+provided", http.StatusSeeOther)
310 +
		return
311 +
	}
312 +
	defer file.Close()
313 +
	data, err := io.ReadAll(file)
314 +
	if err != nil {
315 +
		http.Redirect(w, r, "/admin/files?error=Failed+to+read+upload", http.StatusSeeOther)
316 +
		return
317 +
	}
318 +
	if int64(len(data)) > uploadMaxBytes {
319 +
		http.Redirect(w, r, "/admin/files?error=File+exceeds+10MB+limit", http.StatusSeeOther)
320 +
		return
321 +
	}
322 +
	originalName := "upload"
323 +
	contentType := "application/octet-stream"
324 +
	if header != nil {
325 +
		originalName = header.Filename
326 +
		if ct := header.Header.Get("Content-Type"); ct != "" {
327 +
			contentType = ct
328 +
		}
329 +
	}
330 +
	ext := ""
331 +
	if i := strings.LastIndex(originalName, "."); i > 0 && i < len(originalName)-1 {
332 +
		ext = originalName[i+1:]
333 +
	}
334 +
	id, _ := auth.GenerateShortID(10)
335 +
	stored := id
336 +
	if ext != "" {
337 +
		stored = id + "." + ext
338 +
	}
339 +
	if err := ensureDir(a.UploadsDir); err != nil {
340 +
		http.Redirect(w, r, "/admin/files?error=Failed+to+save+file", http.StatusSeeOther)
341 +
		return
342 +
	}
343 +
	if err := writeFile(joinPath(a.UploadsDir, stored), data); err != nil {
344 +
		a.Log.Error("write file", "err", err)
345 +
		http.Redirect(w, r, "/admin/files?error=Failed+to+save+file", http.StatusSeeOther)
346 +
		return
347 +
	}
348 +
	if _, err := createFile(a.DB, stored, originalName, contentType, int64(len(data))); err != nil {
349 +
		a.Log.Error("record file", "err", err)
350 +
		_ = removeFile(joinPath(a.UploadsDir, stored))
351 +
		http.Redirect(w, r, "/admin/files?error=Failed+to+record+file", http.StatusSeeOther)
352 +
		return
353 +
	}
354 +
	http.Redirect(w, r, "/admin/files?success=true", http.StatusSeeOther)
355 +
}
356 +
357 +
func (a *App) adminDeleteFile(w http.ResponseWriter, r *http.Request) {
358 +
	file, err := deleteFile(a.DB, r.PathValue("id"))
359 +
	if err != nil || file == nil {
360 +
		http.Redirect(w, r, "/admin/files", http.StatusSeeOther)
361 +
		return
362 +
	}
363 +
	_ = removeFile(joinPath(a.UploadsDir, file.Filename))
364 +
	http.Redirect(w, r, "/admin/files", http.StatusSeeOther)
365 +
}
366 +
367 +
func (a *App) adminDownloadPosts(w http.ResponseWriter, r *http.Request) {
368 +
	posts, err := getAllPosts(a.DB)
369 +
	if err != nil {
370 +
		http.Error(w, "Server error", http.StatusInternalServerError)
371 +
		return
372 +
	}
373 +
	var buf bytes.Buffer
374 +
	zw := zip.NewWriter(&buf)
375 +
	for i := range posts {
376 +
		f, err := zw.Create(posts[i].Slug + ".md")
377 +
		if err != nil {
378 +
			continue
379 +
		}
380 +
		_, _ = f.Write([]byte(postToMarkdown(&posts[i])))
381 +
	}
382 +
	_ = zw.Close()
383 +
	w.Header().Set("Content-Type", "application/zip")
384 +
	w.Header().Set("Content-Disposition", `attachment; filename="posts.zip"`)
385 +
	_, _ = w.Write(buf.Bytes())
386 +
}
387 +
388 +
func (a *App) adminDownloadUploads(w http.ResponseWriter, r *http.Request) {
389 +
	files, err := getAllFiles(a.DB)
390 +
	if err != nil {
391 +
		http.Error(w, "Server error", http.StatusInternalServerError)
392 +
		return
393 +
	}
394 +
	var buf bytes.Buffer
395 +
	zw := zip.NewWriter(&buf)
396 +
	header := &zip.FileHeader{Method: zip.Store}
397 +
	_ = header
398 +
	seen := map[string]bool{}
399 +
	for _, file := range files {
400 +
		data, err := readFileImpl(joinPath(a.UploadsDir, file.Filename))
401 +
		if err != nil {
402 +
			continue
403 +
		}
404 +
		name := file.OriginalName
405 +
		if seen[name] {
406 +
			name = file.ShortID + "_" + file.OriginalName
407 +
		}
408 +
		seen[file.OriginalName] = true
409 +
		w2, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store})
410 +
		if err != nil {
411 +
			continue
412 +
		}
413 +
		_, _ = w2.Write(data)
414 +
	}
415 +
	_ = zw.Close()
416 +
	w.Header().Set("Content-Type", "application/zip")
417 +
	w.Header().Set("Content-Disposition", `attachment; filename="uploads.zip"`)
418 +
	_, _ = w.Write(buf.Bytes())
419 +
}
420 +
421 +
func (a *App) adminImportForm(w http.ResponseWriter, r *http.Request) {
422 +
	data := adminImportPageData{Error: r.URL.Query().Get("error")}
423 +
	if v := r.URL.Query().Get("imported"); v != "" {
424 +
		var n int
425 +
		_, _ = fmt.Sscanf(v, "%d", &n)
426 +
		data.Imported = &n
427 +
	}
428 +
	if v := r.URL.Query().Get("skipped"); v != "" {
429 +
		var n int
430 +
		_, _ = fmt.Sscanf(v, "%d", &n)
431 +
		data.Skipped = &n
432 +
	}
433 +
	web.Render(a.Templates, w, "admin_import.html", data, a.Log)
434 +
}
435 +
436 +
func (a *App) adminImportPosts(w http.ResponseWriter, r *http.Request) {
437 +
	r.Body = http.MaxBytesReader(w, r.Body, bodyLimit)
438 +
	if err := r.ParseMultipartForm(importMaxBytes); err != nil {
439 +
		http.Redirect(w, r, "/admin/import?error=Failed+to+read+upload", http.StatusSeeOther)
440 +
		return
441 +
	}
442 +
	file, _, err := r.FormFile("zip")
443 +
	if err != nil {
444 +
		http.Redirect(w, r, "/admin/import?error=No+zip+provided", http.StatusSeeOther)
445 +
		return
446 +
	}
447 +
	defer file.Close()
448 +
	data, err := io.ReadAll(file)
449 +
	if err != nil {
450 +
		http.Redirect(w, r, "/admin/import?error=Failed+to+read+upload", http.StatusSeeOther)
451 +
		return
452 +
	}
453 +
	if int64(len(data)) > importMaxBytes {
454 +
		http.Redirect(w, r, "/admin/import?error=Zip+exceeds+50MB+limit", http.StatusSeeOther)
455 +
		return
456 +
	}
457 +
	imported, skipped, err := a.processImportZip(data)
458 +
	if err != nil {
459 +
		a.Log.Error("import zip", "err", err)
460 +
		http.Redirect(w, r, "/admin/import?error=Invalid+zip+archive", http.StatusSeeOther)
461 +
		return
462 +
	}
463 +
	http.Redirect(w, r, fmt.Sprintf("/admin/import?imported=%d&skipped=%d", imported, skipped), http.StatusSeeOther)
464 +
}
465 +
466 +
func (a *App) processImportZip(data []byte) (int, int, error) {
467 +
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
468 +
	if err != nil {
469 +
		return 0, 0, err
470 +
	}
471 +
	imported, skipped := 0, 0
472 +
	for _, f := range zr.File {
473 +
		if f.FileInfo().IsDir() {
474 +
			continue
475 +
		}
476 +
		name := f.Name
477 +
		if strings.HasPrefix(name, "__MACOSX/") {
478 +
			continue
479 +
		}
480 +
		base := name
481 +
		if i := strings.LastIndex(name, "/"); i >= 0 {
482 +
			base = name[i+1:]
483 +
		}
484 +
		if base == "" || strings.HasPrefix(base, ".") {
485 +
			continue
486 +
		}
487 +
		low := strings.ToLower(base)
488 +
		if !strings.HasSuffix(low, ".md") && !strings.HasSuffix(low, ".markdown") {
489 +
			continue
490 +
		}
491 +
		rc, err := f.Open()
492 +
		if err != nil {
493 +
			continue
494 +
		}
495 +
		raw, err := io.ReadAll(rc)
496 +
		rc.Close()
497 +
		if err != nil {
498 +
			continue
499 +
		}
500 +
		if a.importOne(base, string(raw), &imported, &skipped) {
501 +
			continue
502 +
		}
503 +
		skipped++
504 +
	}
505 +
	return imported, skipped, nil
506 +
}
507 +
508 +
func (a *App) importOne(basename, raw string, imported, skipped *int) bool {
509 +
	fm, body := splitFrontmatter(raw)
510 +
	attrs := parseAttributes(fm)
511 +
	title := strings.TrimSpace(attrs.Title)
512 +
	if title == "" {
513 +
		title = titleFromFilename(basename)
514 +
	}
515 +
	slug := deriveSlugWith(a, title, strings.TrimSpace(attrs.Slug))
516 +
	if slug == "" {
517 +
		return false
518 +
	}
519 +
	if existing, _ := getPostBySlug(a.DB, slug); existing != nil {
520 +
		*skipped++
521 +
		return true
522 +
	}
523 +
	status := "draft"
524 +
	if strings.EqualFold(strings.TrimSpace(attrs.Status), "published") {
525 +
		status = "published"
526 +
	}
527 +
	lang := "en"
528 +
	if l := strings.TrimSpace(attrs.Lang); l != "" {
529 +
		lang = l
530 +
	}
531 +
	pub := strings.TrimSpace(attrs.PublishedDate)
532 +
	if pub == "" {
533 +
		pub = nowDatetime()
534 +
	}
535 +
	in := PostInput{
536 +
		Title: optStr(title), Slug: slug, Content: body, Status: status,
537 +
		Alias:           optStr(attrs.Alias),
538 +
		PublishedDate:   &pub,
539 +
		MetaDescription: optStr(attrs.MetaDescription),
540 +
		MetaImage:       optStr(attrs.MetaImage),
541 +
		Lang:            lang, Tags: optStr(attrs.Tags),
542 +
	}
543 +
	if _, err := createPost(a.DB, in); err != nil {
544 +
		a.Log.Warn("import insert failed", "slug", slug, "err", err)
545 +
		return false
546 +
	}
547 +
	*imported++
548 +
	return true
549 +
}
550 +
551 +
var _ = url.QueryEscape
apps/posts-go/handlers_api.go (added) +93 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strconv"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
const defaultListLimit int64 = 30
11 +
12 +
type apiPostSummary struct {
13 +
	ShortID         string  `json:"short_id"`
14 +
	Title           *string `json:"title"`
15 +
	Slug            string  `json:"slug"`
16 +
	PublishedDate   *string `json:"published_date"`
17 +
	MetaDescription *string `json:"meta_description"`
18 +
	MetaImage       *string `json:"meta_image"`
19 +
	CanonicalURL    *string `json:"canonical_url"`
20 +
	Lang            string  `json:"lang"`
21 +
	Tags            *string `json:"tags"`
22 +
	Content         string  `json:"content"`
23 +
	CreatedAt       string  `json:"created_at"`
24 +
	UpdatedAt       string  `json:"updated_at"`
25 +
}
26 +
27 +
type apiPostDetail struct {
28 +
	ShortID         string  `json:"short_id"`
29 +
	Title           *string `json:"title"`
30 +
	Slug            string  `json:"slug"`
31 +
	Alias           *string `json:"alias"`
32 +
	CanonicalURL    *string `json:"canonical_url"`
33 +
	PublishedDate   *string `json:"published_date"`
34 +
	MetaDescription *string `json:"meta_description"`
35 +
	MetaImage       *string `json:"meta_image"`
36 +
	Lang            string  `json:"lang"`
37 +
	Tags            *string `json:"tags"`
38 +
	Content         string  `json:"content"`
39 +
	CreatedAt       string  `json:"created_at"`
40 +
	UpdatedAt       string  `json:"updated_at"`
41 +
}
42 +
43 +
func toSummary(p Post) apiPostSummary {
44 +
	return apiPostSummary{
45 +
		ShortID: p.ShortID, Title: p.Title, Slug: p.Slug,
46 +
		PublishedDate: p.PublishedDate, MetaDescription: p.MetaDescription,
47 +
		MetaImage: p.MetaImage, CanonicalURL: p.CanonicalURL,
48 +
		Lang: p.Lang, Tags: p.Tags, Content: p.Content,
49 +
		CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
50 +
	}
51 +
}
52 +
53 +
func toDetail(p Post) apiPostDetail {
54 +
	return apiPostDetail{
55 +
		ShortID: p.ShortID, Title: p.Title, Slug: p.Slug,
56 +
		Alias: p.Alias, CanonicalURL: p.CanonicalURL,
57 +
		PublishedDate: p.PublishedDate, MetaDescription: p.MetaDescription,
58 +
		MetaImage: p.MetaImage, Lang: p.Lang, Tags: p.Tags,
59 +
		Content: p.Content, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
60 +
	}
61 +
}
62 +
63 +
func (a *App) apiListPosts(w http.ResponseWriter, r *http.Request) {
64 +
	limit := defaultListLimit
65 +
	if v := r.URL.Query().Get("limit"); v != "" {
66 +
		if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
67 +
			limit = n
68 +
		}
69 +
	}
70 +
	posts, err := getPublishedPosts(a.DB, limit)
71 +
	if err != nil {
72 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "internal server error"})
73 +
		return
74 +
	}
75 +
	out := make([]apiPostSummary, 0, len(posts))
76 +
	for _, p := range posts {
77 +
		out = append(out, toSummary(p))
78 +
	}
79 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"posts": out})
80 +
}
81 +
82 +
func (a *App) apiGetPost(w http.ResponseWriter, r *http.Request) {
83 +
	post, err := getPostBySlug(a.DB, r.PathValue("slug"))
84 +
	if err != nil {
85 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "internal server error"})
86 +
		return
87 +
	}
88 +
	if post == nil || post.Status != "published" {
89 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
90 +
		return
91 +
	}
92 +
	web.WriteJSON(w, http.StatusOK, toDetail(*post))
93 +
}
apps/posts-go/handlers_public.go (added) +234 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"net/http"
6 +
	"strings"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
10 +
11 +
func (a *App) site() siteContext {
12 +
	blogTitle, _ := getSetting(a.DB, "blog_title")
13 +
	if blogTitle == "" {
14 +
		blogTitle = "My Blog"
15 +
	}
16 +
	navLinksRaw, _ := getSetting(a.DB, "nav_links")
17 +
	favicon, _ := getSetting(a.DB, "favicon_url")
18 +
	ogImage, _ := getSetting(a.DB, "og_image_url")
19 +
	header, _ := getSetting(a.DB, "custom_header")
20 +
	footer, _ := getSetting(a.DB, "custom_footer")
21 +
	return siteContext{
22 +
		BlogTitle:  blogTitle,
23 +
		NavLinks:   parseNavLinks(navLinksRaw),
24 +
		FaviconURL: favicon,
25 +
		OGImageURL: ogImage,
26 +
		SiteURL:    a.SiteURL,
27 +
		HeaderHTML: template.HTML(renderMarkdown(header)),
28 +
		FooterHTML: template.HTML(renderMarkdown(footer)),
29 +
	}
30 +
}
31 +
32 +
func renderLatestPostsEmbed(posts []Post) string {
33 +
	var b strings.Builder
34 +
	b.WriteString(`<div class="post-list">`)
35 +
	for i := range posts {
36 +
		p := &posts[i]
37 +
		b.WriteString(`<a href="/posts/` + p.Slug + `" class="post-item"><div class="post-item-info"><span class="post-title">` + p.DisplayTitle() + `</span>`)
38 +
		if p.Tags != nil && *p.Tags != "" {
39 +
			b.WriteString(`<span class="post-tags">`)
40 +
			for _, t := range strings.Split(*p.Tags, ",") {
41 +
				if v := strings.TrimSpace(t); v != "" {
42 +
					b.WriteString(`<span class="tag">` + v + `</span>`)
43 +
				}
44 +
			}
45 +
			b.WriteString(`</span>`)
46 +
		}
47 +
		b.WriteString(`</div>`)
48 +
		if p.PublishedDate != nil {
49 +
			b.WriteString(`<time class="post-date">` + *p.PublishedDate + `</time>`)
50 +
		}
51 +
		b.WriteString(`</a>`)
52 +
	}
53 +
	b.WriteString(`</div>`)
54 +
	return b.String()
55 +
}
56 +
57 +
func (a *App) publicIndex(w http.ResponseWriter, r *http.Request) {
58 +
	ctx := a.site()
59 +
	blogDesc, _ := getSetting(a.DB, "blog_description")
60 +
	intro, _ := getSetting(a.DB, "intro_content")
61 +
62 +
	posts, err := getPublishedPosts(a.DB, 0)
63 +
	if err != nil {
64 +
		a.Log.Error("list posts", "err", err)
65 +
		http.Error(w, "Server error", http.StatusInternalServerError)
66 +
		return
67 +
	}
68 +
69 +
	introHTML := renderMarkdown(intro)
70 +
	if strings.Contains(intro, "{{latest_posts}}") {
71 +
		take := len(posts)
72 +
		if take > 5 {
73 +
			take = 5
74 +
		}
75 +
		embed := renderLatestPostsEmbed(posts[:take])
76 +
		introHTML = strings.ReplaceAll(introHTML, "<p>{{latest_posts}}</p>", embed)
77 +
		introHTML = strings.ReplaceAll(introHTML, "{{latest_posts}}", embed)
78 +
	}
79 +
80 +
	web.Render(a.Templates, w, "index.html", indexPageData{
81 +
		BlogTitle: ctx.BlogTitle, BlogDescription: blogDesc,
82 +
		IntroHTML: template.HTML(introHTML),
83 +
		Posts:     posts,
84 +
		NavLinks:  ctx.NavLinks, FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
85 +
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
86 +
	}, a.Log)
87 +
}
88 +
89 +
func (a *App) publicPost(w http.ResponseWriter, r *http.Request) {
90 +
	slug := r.PathValue("slug")
91 +
	post, err := getPostBySlug(a.DB, slug)
92 +
	if err != nil {
93 +
		http.Error(w, "Server error", http.StatusInternalServerError)
94 +
		return
95 +
	}
96 +
	if post == nil || post.Status != "published" {
97 +
		http.Error(w, "Not found", http.StatusNotFound)
98 +
		return
99 +
	}
100 +
	ctx := a.site()
101 +
	rendered := renderMarkdown(post.Content)
102 +
	web.Render(a.Templates, w, "post.html", postPageData{
103 +
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Post: *post,
104 +
		RenderedContent: template.HTML(rendered),
105 +
		FaviconURL:      ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
106 +
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
107 +
	}, a.Log)
108 +
}
109 +
110 +
func (a *App) publicPage(w http.ResponseWriter, r *http.Request) {
111 +
	slug := r.PathValue("slug")
112 +
	page, err := getPageBySlug(a.DB, slug)
113 +
	if err != nil {
114 +
		http.Error(w, "Server error", http.StatusInternalServerError)
115 +
		return
116 +
	}
117 +
	if page == nil || !page.IsPublished {
118 +
		// fallback: alias redirect or 404
119 +
		if redirect, err := findAliasRedirect(a.DB, slug); err == nil && redirect != "" {
120 +
			http.Redirect(w, r, redirect, http.StatusMovedPermanently)
121 +
			return
122 +
		}
123 +
		http.Error(w, "Not found", http.StatusNotFound)
124 +
		return
125 +
	}
126 +
	ctx := a.site()
127 +
	rendered := renderMarkdown(page.Content)
128 +
	web.Render(a.Templates, w, "page.html", pagePageData{
129 +
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Page: *page,
130 +
		RenderedContent: template.HTML(rendered),
131 +
		FaviconURL:      ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
132 +
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
133 +
	}, a.Log)
134 +
}
135 +
136 +
func (a *App) publicPostsList(w http.ResponseWriter, r *http.Request) {
137 +
	ctx := a.site()
138 +
	posts, err := getPublishedPosts(a.DB, 0)
139 +
	if err != nil {
140 +
		http.Error(w, "Server error", http.StatusInternalServerError)
141 +
		return
142 +
	}
143 +
	web.Render(a.Templates, w, "posts.html", postsListPageData{
144 +
		BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Posts: posts,
145 +
		FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL,
146 +
		SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML,
147 +
	}, a.Log)
148 +
}
149 +
150 +
func (a *App) customCSS(w http.ResponseWriter, r *http.Request) {
151 +
	css, _ := getSetting(a.DB, "custom_css")
152 +
	w.Header().Set("Content-Type", "text/css")
153 +
	_, _ = w.Write([]byte(css))
154 +
}
155 +
156 +
func (a *App) serveUploadedFile(w http.ResponseWriter, r *http.Request) {
157 +
	filename := r.PathValue("filename")
158 +
	if strings.Contains(filename, "..") || strings.ContainsAny(filename, "/\\") {
159 +
		http.NotFound(w, r)
160 +
		return
161 +
	}
162 +
	path := a.UploadsDir + "/" + filename
163 +
	data, err := readFile(path)
164 +
	if err != nil {
165 +
		http.NotFound(w, r)
166 +
		return
167 +
	}
168 +
	ct := mimeFromPath(filename)
169 +
	if f, _ := getFileByFilename(a.DB, filename); f != nil && f.ContentType != "" {
170 +
		ct = f.ContentType
171 +
	}
172 +
	w.Header().Set("Content-Type", ct)
173 +
	_, _ = w.Write(data)
174 +
}
175 +
176 +
func (a *App) rssFeed(w http.ResponseWriter, r *http.Request) {
177 +
	blogTitle, _ := getSetting(a.DB, "blog_title")
178 +
	if blogTitle == "" {
179 +
		blogTitle = "My Blog"
180 +
	}
181 +
	blogDesc, _ := getSetting(a.DB, "blog_description")
182 +
	posts, err := getPublishedPosts(a.DB, 0)
183 +
	if err != nil {
184 +
		http.Error(w, "Server error", http.StatusInternalServerError)
185 +
		return
186 +
	}
187 +
	var items strings.Builder
188 +
	for _, p := range posts {
189 +
		link := a.SiteURL + "/posts/" + xmlEscape(p.Slug)
190 +
		title := ""
191 +
		if p.Title != nil {
192 +
			if t := strings.TrimSpace(*p.Title); t != "" {
193 +
				title = xmlEscape(t)
194 +
			}
195 +
		}
196 +
		desc := ""
197 +
		if p.MetaDescription != nil && *p.MetaDescription != "" {
198 +
			desc = xmlEscape(*p.MetaDescription)
199 +
		} else {
200 +
			runes := []rune(p.Content)
201 +
			n := 200
202 +
			if len(runes) < n {
203 +
				n = len(runes)
204 +
			}
205 +
			desc = xmlEscape(string(runes[:n]))
206 +
		}
207 +
		rawDate := p.CreatedAt
208 +
		if p.PublishedDate != nil {
209 +
			rawDate = *p.PublishedDate
210 +
		}
211 +
		pubDate := toRFC2822(rawDate)
212 +
		items.WriteString("    <item>\n      <title>" + title + "</title>\n      <link>" + link + "</link>\n      <guid>" + link + "</guid>\n      <description>" + desc + "</description>\n      <pubDate>" + pubDate + "</pubDate>\n    </item>\n")
213 +
	}
214 +
	lastBuild := ""
215 +
	if len(posts) > 0 && posts[0].PublishedDate != nil {
216 +
		lastBuild = toRFC2822(*posts[0].PublishedDate)
217 +
	}
218 +
	out := `<?xml version="1.0" encoding="UTF-8"?>
219 +
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
220 +
  <channel>
221 +
    <title>` + xmlEscape(blogTitle) + `</title>
222 +
    <link>` + a.SiteURL + `</link>
223 +
    <description>` + xmlEscape(blogDesc) + `</description>
224 +
    <lastBuildDate>` + lastBuild + `</lastBuildDate>
225 +
    <atom:link href="` + a.SiteURL + `/feed.xml" rel="self" type="application/rss+xml"/>
226 +
` + items.String() + `  </channel>
227 +
</rss>`
228 +
	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
229 +
	_, _ = w.Write([]byte(out))
230 +
}
231 +
232 +
func readFile(path string) ([]byte, error) {
233 +
	return readFileImpl(path)
234 +
}
apps/posts-go/main.go (added) +60 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
	"strings"
10 +
11 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 +
	"github.com/stevedylandev/andromeda/crates-go/config"
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("POSTS_DB_PATH", "posts.sqlite")
20 +
	db, err := openDB(dbPath)
21 +
	if err != nil {
22 +
		log.Fatal(err)
23 +
	}
24 +
	defer db.Close()
25 +
26 +
	uploadsDir := config.Getenv("UPLOADS_DIR", "uploads")
27 +
	if err := ensureDir(uploadsDir); err != nil {
28 +
		log.Fatalf("create uploads dir: %v", err)
29 +
	}
30 +
31 +
	password := os.Getenv("POSTS_PASSWORD")
32 +
	if password == "" {
33 +
		logger.Warn("POSTS_PASSWORD not set, using default 'changeme'")
34 +
		password = "changeme"
35 +
	}
36 +
37 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
38 +
	if err := sessions.EnsureSchema(); err != nil {
39 +
		log.Fatal(err)
40 +
	}
41 +
	sessions.PruneExpired()
42 +
43 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
44 +
	app := &App{
45 +
		DB:           db,
46 +
		Log:          logger,
47 +
		Templates:    tmpl,
48 +
		Sessions:     sessions,
49 +
		AppPassword:  password,
50 +
		CookieSecure: sessions.CookieSecure,
51 +
		UploadsDir:   uploadsDir,
52 +
		SiteURL:      strings.TrimRight(config.Getenv("SITE_URL", "http://localhost:3000"), "/"),
53 +
	}
54 +
55 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
56 +
	logger.Info("posts-go server running", "addr", addr)
57 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
58 +
		log.Fatal(err)
59 +
	}
60 +
}
apps/posts-go/markdown.go (added) +24 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
6 +
	"github.com/yuin/goldmark"
7 +
	"github.com/yuin/goldmark/extension"
8 +
	"github.com/yuin/goldmark/parser"
9 +
	"github.com/yuin/goldmark/renderer/html"
10 +
)
11 +
12 +
var md = goldmark.New(
13 +
	goldmark.WithExtensions(extension.GFM, extension.Footnote),
14 +
	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
15 +
	goldmark.WithRendererOptions(html.WithUnsafe()),
16 +
)
17 +
18 +
func renderMarkdown(src string) string {
19 +
	var buf bytes.Buffer
20 +
	if err := md.Convert([]byte(src), &buf); err != nil {
21 +
		return src
22 +
	}
23 +
	return buf.String()
24 +
}
apps/posts-go/routes.go (added) +84 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strings"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
10 +
11 +
func (a *App) routes() *http.ServeMux {
12 +
	mux := http.NewServeMux()
13 +
14 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
15 +
		return a.Sessions.RequireSession("/admin/login", next)
16 +
	}
17 +
	cors := func(next http.HandlerFunc) http.HandlerFunc {
18 +
		return func(w http.ResponseWriter, r *http.Request) {
19 +
			w.Header().Set("Access-Control-Allow-Origin", "*")
20 +
			w.Header().Set("Access-Control-Allow-Methods", "GET")
21 +
			w.Header().Set("Access-Control-Allow-Headers", "*")
22 +
			next(w, r)
23 +
		}
24 +
	}
25 +
26 +
	// Public
27 +
	mux.HandleFunc("GET /{$}", a.publicIndex)
28 +
	mux.HandleFunc("GET /posts", a.publicPostsList)
29 +
	mux.HandleFunc("GET /posts/{slug}", a.publicPost)
30 +
	mux.HandleFunc("GET /custom-styles.css", a.customCSS)
31 +
	mux.HandleFunc("GET /feed.xml", a.rssFeed)
32 +
	mux.HandleFunc("GET /files/{filename}", a.serveUploadedFile)
33 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
34 +
	darkmatter.Mount(mux, "/assets")
35 +
36 +
	// API
37 +
	mux.HandleFunc("GET /api/posts", cors(a.apiListPosts))
38 +
	mux.HandleFunc("GET /api/posts/{slug}", cors(a.apiGetPost))
39 +
40 +
	// Admin auth
41 +
	mux.HandleFunc("GET /admin/login", a.loginGet)
42 +
	mux.HandleFunc("POST /admin/login", a.loginPost)
43 +
	mux.HandleFunc("GET /admin/logout", a.logout)
44 +
45 +
	// Admin posts
46 +
	mux.HandleFunc("GET /admin", requireSession(a.adminIndex))
47 +
	mux.HandleFunc("GET /admin/posts/new", requireSession(a.adminNewPost))
48 +
	mux.HandleFunc("POST /admin/posts", requireSession(a.adminCreatePost))
49 +
	mux.HandleFunc("GET /admin/posts/{id}/edit", requireSession(a.adminEditPost))
50 +
	mux.HandleFunc("POST /admin/posts/{id}", requireSession(a.adminUpdatePost))
51 +
	mux.HandleFunc("POST /admin/posts/{id}/delete", requireSession(a.adminDeletePost))
52 +
	mux.HandleFunc("POST /admin/posts/{id}/publish", requireSession(a.adminTogglePublish))
53 +
54 +
	// Admin pages
55 +
	mux.HandleFunc("GET /admin/pages", requireSession(a.adminPages))
56 +
	mux.HandleFunc("GET /admin/pages/new", requireSession(a.adminNewPage))
57 +
	mux.HandleFunc("POST /admin/pages/create", requireSession(a.adminCreatePage))
58 +
	mux.HandleFunc("GET /admin/pages/{id}/edit", requireSession(a.adminEditPage))
59 +
	mux.HandleFunc("POST /admin/pages/{id}", requireSession(a.adminUpdatePage))
60 +
	mux.HandleFunc("POST /admin/pages/{id}/delete", requireSession(a.adminDeletePage))
61 +
62 +
	// Settings
63 +
	mux.HandleFunc("GET /admin/settings", requireSession(a.adminGetSettings))
64 +
	mux.HandleFunc("POST /admin/settings", requireSession(a.adminPostSettings))
65 +
66 +
	// Downloads
67 +
	mux.HandleFunc("GET /admin/downloads/posts", requireSession(a.adminDownloadPosts))
68 +
	mux.HandleFunc("GET /admin/downloads/uploads", requireSession(a.adminDownloadUploads))
69 +
70 +
	// Import
71 +
	mux.HandleFunc("GET /admin/import", requireSession(a.adminImportForm))
72 +
	mux.HandleFunc("POST /admin/import", requireSession(a.adminImportPosts))
73 +
74 +
	// Files
75 +
	mux.HandleFunc("GET /admin/files", requireSession(a.adminFiles))
76 +
	mux.HandleFunc("POST /admin/files/upload", requireSession(a.adminUploadFile))
77 +
	mux.HandleFunc("POST /admin/files/{id}/delete", requireSession(a.adminDeleteFile))
78 +
79 +
	// Fallback: /{slug} → page or alias redirect
80 +
	mux.HandleFunc("GET /{slug}", a.publicPage)
81 +
82 +
	_ = strings.Trim
83 +
	return mux
84 +
}
apps/posts-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/posts-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/posts-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/posts-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/posts-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/posts-go/static/styles.css (added) +271 −0
1 +
/* posts — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
/* Textarea variants */
6 +
7 +
textarea.post-content {
8 +
  min-height: 500px;
9 +
}
10 +
11 +
textarea.attributes-textarea {
12 +
  min-height: 80px;
13 +
}
14 +
15 +
.nav-links-input {
16 +
  min-height: 40px;
17 +
  height: 40px;
18 +
}
19 +
20 +
.available-fields {
21 +
  margin-top: 0.5rem;
22 +
}
23 +
24 +
.available-fields > summary {
25 +
  cursor: pointer;
26 +
  user-select: none;
27 +
  font-size: 0.85rem;
28 +
  opacity: 0.6;
29 +
}
30 +
31 +
.fields-list {
32 +
  display: flex;
33 +
  flex-direction: column;
34 +
  gap: 0.15rem;
35 +
  margin-top: 0.25rem;
36 +
  font-size: 0.85rem;
37 +
  opacity: 0.6;
38 +
}
39 +
40 +
/* Post list (public) */
41 +
42 +
.post-list {
43 +
  display: flex;
44 +
  flex-direction: column;
45 +
  width: 100%;
46 +
  gap: 0.5rem;
47 +
}
48 +
49 +
.post-item {
50 +
  display: flex;
51 +
  justify-content: space-between;
52 +
  align-items: center;
53 +
  padding: 8px 0;
54 +
  text-decoration: none;
55 +
}
56 +
57 +
.post-item:hover {
58 +
  opacity: 0.7;
59 +
}
60 +
61 +
.post-item-info {
62 +
  display: flex;
63 +
  flex-direction: column;
64 +
  gap: 0.25rem;
65 +
}
66 +
67 +
.post-item-enhanced .post-item-info {
68 +
  gap: 0.25rem;
69 +
}
70 +
71 +
.post-title {
72 +
  font-size: 16px;
73 +
}
74 +
75 +
.post-description {
76 +
  font-style: italic;
77 +
  opacity: 0.7;
78 +
}
79 +
80 +
.post-date {
81 +
  font-size: 12px;
82 +
  opacity: 0.5;
83 +
}
84 +
85 +
.post-excerpt {
86 +
  font-size: 12px;
87 +
  opacity: 0.6;
88 +
  line-height: 1.4;
89 +
}
90 +
91 +
.post-tags {
92 +
  display: flex;
93 +
  gap: 0.4rem;
94 +
  flex-wrap: wrap;
95 +
}
96 +
97 +
/* Post header (public single) */
98 +
99 +
.post-header {
100 +
  display: flex;
101 +
  flex-direction: column;
102 +
  gap: 0.25rem;
103 +
}
104 +
105 +
.post-header h1 {
106 +
  font-size: 24px;
107 +
  font-weight: 700;
108 +
  letter-spacing: -0.5px;
109 +
}
110 +
111 +
.page-header {
112 +
  display: flex;
113 +
  flex-direction: column;
114 +
  gap: 0.25rem;
115 +
}
116 +
117 +
.page-header h1 {
118 +
  font-size: 24px;
119 +
  font-weight: 700;
120 +
  letter-spacing: -0.5px;
121 +
}
122 +
123 +
.intro {
124 +
  padding-bottom: 1rem;
125 +
  border-bottom: 1px solid #333;
126 +
}
127 +
128 +
/* Markdown rendered content */
129 +
130 +
.markdown-body {
131 +
  width: 100%;
132 +
  line-height: 1.6;
133 +
}
134 +
135 +
.markdown-body h1,
136 +
.markdown-body h2,
137 +
.markdown-body h3,
138 +
.markdown-body h4,
139 +
.markdown-body h5,
140 +
.markdown-body h6 {
141 +
  margin-top: 1.5rem;
142 +
  margin-bottom: 0.5rem;
143 +
  font-weight: 700;
144 +
}
145 +
146 +
.markdown-body h1 { font-size: 18px; }
147 +
.markdown-body h2 { font-size: 16px; }
148 +
.markdown-body h3 { font-size: 15px; }
149 +
.markdown-body h4,
150 +
.markdown-body h5,
151 +
.markdown-body h6 { font-size: 14px; }
152 +
153 +
.markdown-body p {
154 +
  margin-bottom: 0.75rem;
155 +
}
156 +
157 +
.markdown-body ul,
158 +
.markdown-body ol {
159 +
  margin-left: 1.5rem;
160 +
  margin-bottom: 0.75rem;
161 +
}
162 +
163 +
.markdown-body li {
164 +
  margin-bottom: 0.25rem;
165 +
}
166 +
167 +
.markdown-body pre {
168 +
  margin-bottom: 0.75rem;
169 +
}
170 +
171 +
.markdown-body blockquote {
172 +
  border-left: 2px solid #555;
173 +
  padding-left: 12px;
174 +
  opacity: 0.7;
175 +
  margin-bottom: 0.75rem;
176 +
}
177 +
178 +
.markdown-body table {
179 +
  margin-bottom: 0.75rem;
180 +
}
181 +
182 +
.markdown-body th,
183 +
.markdown-body td {
184 +
  border: 1px solid #333;
185 +
}
186 +
187 +
.markdown-body hr {
188 +
  border: none;
189 +
  border-top: 1px solid #333;
190 +
  margin: 1rem 0;
191 +
}
192 +
193 +
.markdown-body a {
194 +
  text-decoration: underline;
195 +
}
196 +
197 +
.markdown-body img {
198 +
  max-width: 100%;
199 +
}
200 +
201 +
.markdown-body li:has(> input[type="checkbox"]) {
202 +
  list-style: none;
203 +
  margin-left: -1.5rem;
204 +
}
205 +
206 +
.markdown-body input[type="checkbox"] {
207 +
  width: 14px;
208 +
  height: 14px;
209 +
  margin-right: 6px;
210 +
  vertical-align: middle;
211 +
  position: relative;
212 +
  top: -1px;
213 +
}
214 +
215 +
.markdown-body input[type="checkbox"]:checked::after {
216 +
  font-size: 12px;
217 +
}
218 +
219 +
/* Footnotes */
220 +
221 +
.markdown-body .footnote-definition {
222 +
  font-size: 12px;
223 +
  opacity: 0.7;
224 +
  margin-bottom: 0.5rem;
225 +
  display: flex;
226 +
  gap: 0.5rem;
227 +
}
228 +
229 +
.markdown-body .footnote-definition-label {
230 +
  font-size: 11px;
231 +
  opacity: 0.5;
232 +
  flex-shrink: 0;
233 +
}
234 +
235 +
.markdown-body .footnote-definition p {
236 +
  margin-bottom: 0;
237 +
}
238 +
239 +
.markdown-body sup.footnote-reference a {
240 +
  font-size: 11px;
241 +
  text-decoration: none;
242 +
  opacity: 0.6;
243 +
}
244 +
245 +
.markdown-body sup.footnote-reference a:hover {
246 +
  opacity: 1;
247 +
}
248 +
249 +
/* File thumbnails */
250 +
251 +
.file-thumbnail {
252 +
  max-width: 60px;
253 +
  max-height: 60px;
254 +
  object-fit: cover;
255 +
  border: 1px solid #333;
256 +
  flex-shrink: 0;
257 +
}
258 +
259 +
/* RSS link in footer */
260 +
261 +
.rss-link {
262 +
  display: flex;
263 +
  align-items: center;
264 +
  gap: 0.4rem;
265 +
  font-size: 12px;
266 +
  opacity: 0.5;
267 +
}
268 +
269 +
.rss-link:hover {
270 +
  opacity: 0.8;
271 +
}
apps/posts-go/storage.go (added) +26 −0
1 +
package main
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
)
7 +
8 +
func readFileImpl(path string) ([]byte, error) {
9 +
	return os.ReadFile(path)
10 +
}
11 +
12 +
func writeFile(path string, data []byte) error {
13 +
	return os.WriteFile(path, data, 0o644)
14 +
}
15 +
16 +
func removeFile(path string) error {
17 +
	return os.Remove(path)
18 +
}
19 +
20 +
func ensureDir(path string) error {
21 +
	return os.MkdirAll(path, 0o755)
22 +
}
23 +
24 +
func joinPath(parts ...string) string {
25 +
	return filepath.Join(parts...)
26 +
}
apps/posts-go/templates/admin_base.html (added) +29 −0
1 +
{{define "admin_base.html"}}<!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 +
  <title>{{block "title" .}}Admin{{end}}</title>
7 +
  <link rel="icon" href="/static/favicon.ico">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
10 +
  <link rel="stylesheet" href="/static/styles.css">
11 +
</head>
12 +
<body>
13 +
  <header class="header">
14 +
    <a href="/admin" class="logo">POSTS</a>
15 +
    <nav class="links">
16 +
      <a href="/admin">posts</a>
17 +
      <a href="/admin/pages">pages</a>
18 +
      <a href="/admin/files">files</a>
19 +
      <a href="/admin/import">import</a>
20 +
      <a href="/admin/settings">settings</a>
21 +
      <a href="/" target="_blank">view site</a>
22 +
      <a href="/admin/logout">logout</a>
23 +
    </nav>
24 +
  </header>
25 +
  <main>
26 +
    {{block "content" .}}{{end}}
27 +
  </main>
28 +
</body>
29 +
</html>{{end}}
apps/posts-go/templates/admin_files.html (added) +49 −0
1 +
{{define "admin_files.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — Files{{end}}
3 +
{{define "content"}}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Files</h2>
6 +
  </div>
7 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
8 +
  {{if .Success}}<p class="success">File uploaded.</p>{{end}}
9 +
  <form method="POST" action="/admin/files/upload" enctype="multipart/form-data" class="form">
10 +
    <label for="file">upload file (max 10MB)</label>
11 +
    <input type="file" id="file" name="file" required>
12 +
    <button type="submit">upload</button>
13 +
  </form>
14 +
  {{if not .Files}}
15 +
    <p class="empty">no files yet</p>
16 +
  {{else}}
17 +
    {{$site := .SiteURL}}
18 +
    <div class="admin-list">
19 +
      {{range .Files}}
20 +
        <div class="admin-list-item">
21 +
          {{if .IsImage}}
22 +
            <img src="/files/{{.Filename}}" class="file-thumbnail" alt="{{.OriginalName}}">
23 +
          {{end}}
24 +
          <div class="admin-list-info">
25 +
            <span class="admin-list-title">{{.OriginalName}}</span>
26 +
            <div class="admin-list-meta">
27 +
              <span class="admin-list-date">{{.ContentType}}</span>
28 +
              <span class="admin-list-date">{{.SizeHuman}}</span>
29 +
              <span class="admin-list-date">{{.CreatedAt}}</span>
30 +
            </div>
31 +
          </div>
32 +
          <div class="admin-list-actions">
33 +
            <button type="button" class="link-button"
34 +
              onclick="navigator.clipboard.writeText('{{$site}}/files/{{.Filename}}');this.textContent='copied!'">
35 +
              copy url
36 +
            </button>
37 +
            <button type="button" class="link-button"
38 +
              onclick="navigator.clipboard.writeText('![{{.OriginalName}}]({{$site}}/files/{{.Filename}})');this.textContent='copied!'">
39 +
              copy md
40 +
            </button>
41 +
            <form method="POST" action="/admin/files/{{.ShortID}}/delete" class="inline-form">
42 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this file?')">delete</button>
43 +
            </form>
44 +
          </div>
45 +
        </div>
46 +
      {{end}}
47 +
    </div>
48 +
  {{end}}
49 +
{{end}}
apps/posts-go/templates/admin_import.html (added) +35 −0
1 +
{{define "admin_import.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — Import{{end}}
3 +
{{define "content"}}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Import posts</h2>
6 +
  </div>
7 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
8 +
  {{if .Imported}}
9 +
    <p class="success">Imported {{.Imported}} posts, skipped {{if .Skipped}}{{.Skipped}}{{else}}0{{end}}.</p>
10 +
  {{end}}
11 +
  <form method="POST" action="/admin/import" enctype="multipart/form-data" class="form">
12 +
    <label for="zip">upload zip of markdown files (max 50MB)</label>
13 +
    <input type="file" id="zip" name="zip" accept=".zip" required>
14 +
    <button type="submit">import</button>
15 +
  </form>
16 +
  <section>
17 +
    <h3>Format</h3>
18 +
    <p>The zip can contain any number of <code>.md</code> or <code>.markdown</code> files. Each file may begin with YAML-style frontmatter:</p>
19 +
    <pre><code>---
20 +
title: My Post
21 +
slug: my-post
22 +
status: published
23 +
published_date: 2025-01-15 10:00:00
24 +
tags: rust, sqlite
25 +
description: A short summary
26 +
lang: en
27 +
---
28 +
29 +
# Hello
30 +
31 +
Post body in markdown.
32 +
</code></pre>
33 +
    <p>Supported keys: <code>title</code>, <code>slug</code>, <code>status</code> (<code>draft</code> or <code>published</code>), <code>published_date</code>, <code>tags</code>, <code>description</code>, <code>meta_image</code>, <code>alias</code>, <code>lang</code>. Files without frontmatter are imported with the title derived from the filename. Posts whose slug already exists are skipped.</p>
34 +
  </section>
35 +
{{end}}
apps/posts-go/templates/admin_index.html (added) +36 −0
1 +
{{define "admin_index.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — Posts{{end}}
3 +
{{define "content"}}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Posts</h2>
6 +
    <a href="/admin/posts/new" class="btn">new post</a>
7 +
  </div>
8 +
  {{if not .Posts}}
9 +
    <p class="empty">no posts yet</p>
10 +
  {{else}}
11 +
    <div class="admin-list">
12 +
      {{range .Posts}}
13 +
        <div class="admin-list-item">
14 +
          <div class="admin-list-info">
15 +
            <a href="/admin/posts/{{.ShortID}}/edit" class="admin-list-title">{{.DisplayTitle}}</a>
16 +
            <div class="admin-list-meta">
17 +
              <span class="status-badge {{if eq .Status "published"}}status-published{{else}}status-draft{{end}}">{{.Status}}</span>
18 +
              <span class="admin-list-date">{{.UpdatedAt}}</span>
19 +
            </div>
20 +
          </div>
21 +
          <div class="admin-list-actions">
22 +
            <a href="/admin/posts/{{.ShortID}}/edit">edit</a>
23 +
            <form method="POST" action="/admin/posts/{{.ShortID}}/publish" class="inline-form">
24 +
              <button type="submit" class="link-button">
25 +
                {{if eq .Status "published"}}unpublish{{else}}publish{{end}}
26 +
              </button>
27 +
            </form>
28 +
            <form method="POST" action="/admin/posts/{{.ShortID}}/delete" class="inline-form">
29 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this post?')">delete</button>
30 +
            </form>
31 +
          </div>
32 +
        </div>
33 +
      {{end}}
34 +
    </div>
35 +
  {{end}}
36 +
{{end}}
apps/posts-go/templates/admin_page_form.html (added) +42 −0
1 +
{{define "admin_page_form.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — {{if .Page}}Edit Page{{else}}New Page{{end}}{{end}}
3 +
{{define "content"}}
4 +
  <h2>{{if .Page}}Edit Page{{else}}New Page{{end}}</h2>
5 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
6 +
  {{$p := .Page}}
7 +
  {{if $p}}
8 +
    <form method="POST" action="/admin/pages/{{$p.ShortID}}" class="form post-form">
9 +
      <textarea name="attributes" class="attributes-textarea">title: {{$p.Title}}
10 +
slug: {{$p.Slug}}
11 +
published: {{$p.IsPublished}}</textarea>
12 +
      <details class="available-fields">
13 +
        <summary>available fields</summary>
14 +
        <div class="fields-list">
15 +
          <span>title: My Page Title</span>
16 +
          <span>slug: my-page-slug</span>
17 +
          <span>published: true</span>
18 +
        </div>
19 +
      </details>
20 +
      <label for="content">content</label>
21 +
      <textarea id="content" name="content" class="post-content">{{$p.Content}}</textarea>
22 +
      <button type="submit">save</button>
23 +
    </form>
24 +
  {{else}}
25 +
    <form method="POST" action="/admin/pages/create" class="form post-form">
26 +
      <textarea name="attributes" class="attributes-textarea">title:
27 +
slug:
28 +
published: false</textarea>
29 +
      <details class="available-fields">
30 +
        <summary>available fields</summary>
31 +
        <div class="fields-list">
32 +
          <span>title: My Page Title</span>
33 +
          <span>slug: my-page-slug</span>
34 +
          <span>published: true</span>
35 +
        </div>
36 +
      </details>
37 +
      <label for="content">content</label>
38 +
      <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea>
39 +
      <button type="submit">save</button>
40 +
    </form>
41 +
  {{end}}
42 +
{{end}}
apps/posts-go/templates/admin_pages.html (added) +34 −0
1 +
{{define "admin_pages.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — Pages{{end}}
3 +
{{define "content"}}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Pages</h2>
6 +
    <a href="/admin/pages/new" class="btn">new page</a>
7 +
  </div>
8 +
  {{if not .Pages}}
9 +
    <p class="empty">no pages yet</p>
10 +
  {{else}}
11 +
    <div class="admin-list">
12 +
      {{range .Pages}}
13 +
        <div class="admin-list-item">
14 +
          <div class="admin-list-info">
15 +
            <a href="/admin/pages/{{.ShortID}}/edit" class="admin-list-title">{{.Title}}</a>
16 +
            <div class="admin-list-meta">
17 +
              <span class="status-badge {{if .IsPublished}}status-published{{else}}status-draft{{end}}">
18 +
                {{if .IsPublished}}published{{else}}draft{{end}}
19 +
              </span>
20 +
              <span class="admin-list-date">/{{.Slug}}</span>
21 +
              <span class="admin-list-date">order: {{.NavOrder}}</span>
22 +
            </div>
23 +
          </div>
24 +
          <div class="admin-list-actions">
25 +
            <a href="/admin/pages/{{.ShortID}}/edit">edit</a>
26 +
            <form method="POST" action="/admin/pages/{{.ShortID}}/delete" class="inline-form">
27 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this page?')">delete</button>
28 +
            </form>
29 +
          </div>
30 +
        </div>
31 +
      {{end}}
32 +
    </div>
33 +
  {{end}}
34 +
{{end}}
apps/posts-go/templates/admin_post_form.html (added) +66 −0
1 +
{{define "admin_post_form.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — {{if .Post}}Edit Post{{else}}New Post{{end}}{{end}}
3 +
{{define "content"}}
4 +
  <h2>{{if .Post}}Edit Post{{else}}New Post{{end}}</h2>
5 +
  {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
6 +
  {{$p := .Post}}
7 +
  {{if $p}}
8 +
    <form method="POST" action="/admin/posts/{{$p.ShortID}}" class="form post-form">
9 +
      <textarea name="attributes" class="attributes-textarea">title: {{$p.TitleStr}}
10 +
slug: {{$p.Slug}}
11 +
{{if $p.PublishedDate}}published_date: {{$p.PublishedDateStr}}
12 +
{{end}}{{if ne $p.Lang "en"}}lang: {{$p.Lang}}
13 +
{{end}}{{if $p.Tags}}tags: {{$p.TagsStr}}
14 +
{{end}}{{if $p.Alias}}alias: {{$p.AliasStr}}
15 +
{{end}}{{if $p.MetaImage}}meta_image: {{$p.MetaImageStr}}
16 +
{{end}}{{if $p.MetaDescription}}description: {{$p.MetaDescriptionStr}}{{end}}</textarea>
17 +
      <details class="available-fields">
18 +
        <summary>available fields</summary>
19 +
        <div class="fields-list">
20 +
          <span>title: My Post Title</span>
21 +
          <span>slug: my-post-title</span>
22 +
          <span>published_date: 2025-01-15 14:30:00</span>
23 +
          <span>lang: en</span>
24 +
          <span>tags: rust, web, tutorial</span>
25 +
          <span>alias: /old/path</span>
26 +
          <span>meta_image: https://example.com/image.jpg</span>
27 +
          <span>description: A short summary of the post</span>
28 +
        </div>
29 +
      </details>
30 +
      <label for="content">content</label>
31 +
      <textarea id="content" name="content" class="post-content">{{$p.Content}}</textarea>
32 +
      <div class="form-actions">
33 +
        {{if eq $p.Status "published"}}
34 +
          <button type="submit" name="action" value="publish">update</button>
35 +
          <button type="submit" name="action" value="draft">unpublish</button>
36 +
        {{else}}
37 +
          <button type="submit" name="action" value="draft">save draft</button>
38 +
          <button type="submit" name="action" value="publish">publish</button>
39 +
        {{end}}
40 +
      </div>
41 +
    </form>
42 +
  {{else}}
43 +
    <form method="POST" action="/admin/posts" class="form post-form">
44 +
      <textarea name="attributes" class="attributes-textarea">title: </textarea>
45 +
      <details class="available-fields">
46 +
        <summary>available fields</summary>
47 +
        <div class="fields-list">
48 +
          <span>title: My Post Title</span>
49 +
          <span>slug: my-post-title</span>
50 +
          <span>published_date: 2025-01-15 14:30:00</span>
51 +
          <span>lang: en</span>
52 +
          <span>tags: rust, web, tutorial</span>
53 +
          <span>alias: /old/path</span>
54 +
          <span>meta_image: https://example.com/image.jpg</span>
55 +
          <span>description: A short summary of the post</span>
56 +
        </div>
57 +
      </details>
58 +
      <label for="content">content</label>
59 +
      <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea>
60 +
      <div class="form-actions">
61 +
        <button type="submit" name="action" value="draft">save draft</button>
62 +
        <button type="submit" name="action" value="publish">publish</button>
63 +
      </div>
64 +
    </form>
65 +
  {{end}}
66 +
{{end}}
apps/posts-go/templates/admin_settings.html (added) +52 −0
1 +
{{define "admin_settings.html"}}{{template "admin_base.html" .}}{{end}}
2 +
{{define "title"}}Admin — Settings{{end}}
3 +
{{define "content"}}
4 +
  <h2>Settings</h2>
5 +
  {{if .Success}}<p class="success">Settings saved.</p>{{end}}
6 +
  <form method="POST" action="/admin/settings" class="form">
7 +
    <label for="blog_title">blog title</label>
8 +
    <input type="text" id="blog_title" name="blog_title" value="{{.BlogTitle}}" required>
9 +
    <label for="blog_description">blog description</label>
10 +
    <input type="text" id="blog_description" name="blog_description" value="{{.BlogDescription}}">
11 +
    <label for="nav_links">navigation links (format: [label](url) [label2](url2))</label>
12 +
    <textarea id="nav_links" name="nav_links" class="nav-links-input">{{.NavLinksRaw}}</textarea>
13 +
    <label for="favicon_url">favicon URL (leave empty for default)</label>
14 +
    <input type="text" id="favicon_url" name="favicon_url" value="{{.FaviconURL}}" placeholder="https://example.com/favicon.png">
15 +
    <label for="og_image_url">default OG image URL (used when posts don't have their own)</label>
16 +
    <input type="text" id="og_image_url" name="og_image_url" value="{{.OGImageURL}}" placeholder="https://example.com/og.png">
17 +
    <label for="intro_content">intro content (markdown, shown on homepage — use &#123;&#123;latest_posts&#125;&#125; to embed recent posts)</label>
18 +
    <textarea id="intro_content" name="intro_content" class="post-content">{{.IntroContent}}</textarea>
19 +
    <div class="switch-row">
20 +
      <label class="switch">
21 +
        <input type="checkbox" id="custom_css_toggle" {{if .CustomCSS}}checked{{end}}>
22 +
        <span class="switch-slider"></span>
23 +
      </label>
24 +
      <span class="switch-label">custom stylesheet</span>
25 +
    </div>
26 +
    <div id="custom_css_section" {{if not .CustomCSS}}class="hidden"{{end}}>
27 +
      <label for="custom_css">custom CSS (overrides default styles)</label>
28 +
      <textarea id="custom_css" name="custom_css" class="post-content">{{if .CustomCSS}}{{.CustomCSS}}{{else}}{{.DefaultCSS}}{{end}}</textarea>
29 +
    </div>
30 +
    <label for="custom_header">custom header (markdown or HTML, shown above nav on all pages)</label>
31 +
    <textarea id="custom_header" name="custom_header" class="nav-links-input">{{.CustomHeader}}</textarea>
32 +
    <label for="custom_footer">custom footer (markdown or HTML, shown at bottom of all pages)</label>
33 +
    <textarea id="custom_footer" name="custom_footer" class="post-content">{{.CustomFooter}}</textarea>
34 +
    <button type="submit">save</button>
35 +
  </form>
36 +
  <h3>Data Export</h3>
37 +
  <div class="form-actions">
38 +
    <a href="/admin/downloads/posts" class="btn">download posts</a>
39 +
    <a href="/admin/downloads/uploads" class="btn">download uploads</a>
40 +
  </div>
41 +
  <script>
42 +
    var toggle = document.getElementById('custom_css_toggle');
43 +
    var section = document.getElementById('custom_css_section');
44 +
    var cssTextarea = document.getElementById('custom_css');
45 +
    toggle.addEventListener('change', function() {
46 +
      section.classList.toggle('hidden', !this.checked);
47 +
    });
48 +
    document.querySelector('form').addEventListener('submit', function() {
49 +
      if (!toggle.checked) { cssTextarea.value = ''; }
50 +
    });
51 +
  </script>
52 +
{{end}}
apps/posts-go/templates/base.html (added) +54 −0
1 +
{{define "base.html"}}<!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 +
  <title>{{block "title" .}}{{.BlogTitle}}{{end}}</title>
7 +
  {{if .FaviconURL}}
8 +
    <link rel="apple-touch-icon" sizes="180x180" href="{{.FaviconURL}}">
9 +
    <link rel="icon" type="image/png" sizes="32x32" href="{{.FaviconURL}}">
10 +
    <link rel="icon" type="image/png" sizes="16x16" href="{{.FaviconURL}}">
11 +
  {{else}}
12 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
13 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
14 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
15 +
    <link rel="manifest" href="/static/site.webmanifest">
16 +
  {{end}}
17 +
  <meta name="theme-color" content="#121113" />
18 +
  {{block "meta" .}}{{end}}
19 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
20 +
  <link rel="stylesheet" href="/static/styles.css">
21 +
  <link rel="stylesheet" href="/custom-styles.css">
22 +
</head>
23 +
<body>
24 +
  {{if .HeaderHTML}}
25 +
  <div class="custom-header">
26 +
    {{.HeaderHTML}}
27 +
  </div>
28 +
  {{end}}
29 +
  <header class="header">
30 +
    <a href="/" class="logo">{{.BlogTitle}}</a>
31 +
    <nav class="links">
32 +
      {{range .NavLinks}}
33 +
        <a href="{{.URL}}">{{.Label}}</a>
34 +
      {{end}}
35 +
    </nav>
36 +
  </header>
37 +
  <main>
38 +
    {{block "content" .}}{{end}}
39 +
  </main>
40 +
  <footer class="footer">
41 +
    {{.FooterHTML}}
42 +
  </footer>
43 +
  <script>
44 +
    document.querySelectorAll('.post-date').forEach(el => {
45 +
      const d = new Date(el.textContent.trim());
46 +
      if (!isNaN(d)) {
47 +
        const day = String(d.getDate()).padStart(2, '0');
48 +
        const mon = d.toLocaleString('en-US', { month: 'short' });
49 +
        el.textContent = `${day} ${mon}, ${d.getFullYear()}`;
50 +
      }
51 +
    });
52 +
  </script>
53 +
</body>
54 +
</html>{{end}}
apps/posts-go/templates/index.html (added) +36 −0
1 +
{{define "index.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.BlogTitle}}{{end}}
3 +
{{define "meta"}}
4 +
  <meta name="description" content="{{.BlogDescription}}">
5 +
  <meta property="og:title" content="{{.BlogTitle}}">
6 +
  <meta property="og:description" content="{{.BlogDescription}}">
7 +
  <meta property="og:type" content="website">
8 +
  <meta property="og:url" content="{{.SiteURL}}">
9 +
  {{if .OGImageURL}}
10 +
    <meta property="og:image" content="{{.OGImageURL}}">
11 +
  {{else}}
12 +
    <meta property="og:image" content="{{.SiteURL}}/static/og.png">
13 +
  {{end}}
14 +
{{end}}
15 +
{{define "content"}}
16 +
  {{if .IntroHTML}}
17 +
    <article class="intro markdown-body">
18 +
      {{.IntroHTML}}
19 +
    </article>
20 +
  {{end}}
21 +
  {{if not .Posts}}
22 +
    <p class="empty">no posts yet</p>
23 +
  {{end}}
24 +
  <div class="post-list">
25 +
    {{range .Posts}}
26 +
      <a href="/posts/{{.Slug}}" class="post-item">
27 +
        <div class="post-item-info">
28 +
          <span class="post-title">{{.DisplayTitle}}</span>
29 +
        </div>
30 +
        {{if .PublishedDate}}
31 +
          <time class="post-date">{{.PublishedDateStr}}</time>
32 +
        {{end}}
33 +
      </a>
34 +
    {{end}}
35 +
  </div>
36 +
{{end}}
apps/posts-go/templates/login.html (added) +25 −0
1 +
{{define "login.html"}}<!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 +
  <title>Login</title>
7 +
  <link rel="icon" href="/static/favicon.ico">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
10 +
  <link rel="stylesheet" href="/static/styles.css">
11 +
</head>
12 +
<body>
13 +
  <header class="header">
14 +
    <span class="logo">POSTS</span>
15 +
  </header>
16 +
  <main>
17 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
18 +
    <form method="POST" action="/admin/login" class="form">
19 +
      <label for="password">password</label>
20 +
      <input type="password" id="password" name="password" autofocus required>
21 +
      <button type="submit">login</button>
22 +
    </form>
23 +
  </main>
24 +
</body>
25 +
</html>{{end}}
apps/posts-go/templates/page.html (added) +18 −0
1 +
{{define "page.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Page.Title}} — {{.BlogTitle}}{{end}}
3 +
{{define "meta"}}
4 +
  {{if .OGImageURL}}
5 +
    <meta property="og:image" content="{{.OGImageURL}}">
6 +
  {{else}}
7 +
    <meta property="og:image" content="{{.SiteURL}}/static/og.png">
8 +
  {{end}}
9 +
  <meta property="og:url" content="{{.SiteURL}}/{{.Page.Slug}}">
10 +
{{end}}
11 +
{{define "content"}}
12 +
  <div class="page-header">
13 +
    <h1>{{.Page.Title}}</h1>
14 +
  </div>
15 +
  <article class="markdown-body">
16 +
    {{.RenderedContent}}
17 +
  </article>
18 +
{{end}}
apps/posts-go/templates/post.html (added) +45 −0
1 +
{{define "post.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}{{.Post.DisplayTitle}} — {{.BlogTitle}}{{end}}
3 +
{{define "meta"}}
4 +
  {{if .Post.MetaDescription}}
5 +
    <meta name="description" content="{{.Post.MetaDescriptionStr}}">
6 +
    <meta property="og:description" content="{{.Post.MetaDescriptionStr}}">
7 +
  {{end}}
8 +
  {{if and .Post.MetaImage .Post.MetaImageStr}}
9 +
    <meta property="og:image" content="{{.Post.MetaImageStr}}">
10 +
  {{else if .OGImageURL}}
11 +
    <meta property="og:image" content="{{.OGImageURL}}">
12 +
  {{else}}
13 +
    <meta property="og:image" content="{{.SiteURL}}/static/og.png">
14 +
  {{end}}
15 +
  {{if .Post.CanonicalURL}}
16 +
    <link rel="canonical" href="{{.Post.CanonicalURLStr}}">
17 +
  {{end}}
18 +
  <meta property="og:title" content="{{.Post.DisplayTitle}}">
19 +
  <meta property="og:type" content="article">
20 +
  <meta property="og:url" content="{{.SiteURL}}/posts/{{.Post.Slug}}">
21 +
  <meta property="article:published_time" content="{{.Post.PublishedDateStr}}">
22 +
{{end}}
23 +
{{define "content"}}
24 +
  <div class="post-header">
25 +
    {{if .Post.HasTitle}}
26 +
      <h1>{{.Post.TitleStr}}</h1>
27 +
    {{end}}
28 +
    {{if .Post.MetaDescription}}
29 +
      <p class="post-description">{{.Post.MetaDescriptionStr}}</p>
30 +
    {{end}}
31 +
    {{if .Post.PublishedDate}}
32 +
      <time class="post-date">{{.Post.PublishedDateStr}}</time>
33 +
    {{end}}
34 +
    {{if .Post.Tags}}
35 +
      <div class="post-tags">
36 +
        {{range .Post.TagList}}
37 +
          <span class="tag">{{.}}</span>
38 +
        {{end}}
39 +
      </div>
40 +
    {{end}}
41 +
  </div>
42 +
  <article class="markdown-body">
43 +
    {{.RenderedContent}}
44 +
  </article>
45 +
{{end}}
apps/posts-go/templates/posts.html (added) +28 −0
1 +
{{define "posts.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}Posts — {{.BlogTitle}}{{end}}
3 +
{{define "meta"}}
4 +
  {{if .OGImageURL}}
5 +
    <meta property="og:image" content="{{.OGImageURL}}">
6 +
  {{else}}
7 +
    <meta property="og:image" content="{{.SiteURL}}/static/og.png">
8 +
  {{end}}
9 +
  <meta property="og:url" content="{{.SiteURL}}/posts">
10 +
{{end}}
11 +
{{define "content"}}
12 +
  <h1>Posts</h1>
13 +
  {{if not .Posts}}
14 +
    <p class="empty">no posts yet</p>
15 +
  {{end}}
16 +
  <div class="post-list">
17 +
    {{range .Posts}}
18 +
      <a href="/posts/{{.Slug}}" class="post-item post-item-enhanced">
19 +
        <div class="post-item-info">
20 +
          <span class="post-title">{{.DisplayTitle}}</span>
21 +
        </div>
22 +
        {{if .PublishedDate}}
23 +
          <time class="post-date">{{.PublishedDateStr}}</time>
24 +
        {{end}}
25 +
      </a>
26 +
    {{end}}
27 +
  </div>
28 +
{{end}}
apps/posts-go/util.go (added) +295 −0
1 +
package main
2 +
3 +
import (
4 +
	"strings"
5 +
	"time"
6 +
)
7 +
8 +
type parsedAttributes struct {
9 +
	Title           string
10 +
	Slug            string
11 +
	Alias           string
12 +
	PublishedDate   string
13 +
	MetaDescription string
14 +
	MetaImage       string
15 +
	Lang            string
16 +
	Tags            string
17 +
	Status          string
18 +
}
19 +
20 +
func parseAttributes(text string) parsedAttributes {
21 +
	var a parsedAttributes
22 +
	for _, line := range strings.Split(text, "\n") {
23 +
		i := strings.Index(line, ":")
24 +
		if i < 0 {
25 +
			continue
26 +
		}
27 +
		key := strings.ToLower(strings.TrimSpace(line[:i]))
28 +
		value := strings.TrimSpace(line[i+1:])
29 +
		switch key {
30 +
		case "title":
31 +
			a.Title = value
32 +
		case "slug":
33 +
			a.Slug = value
34 +
		case "alias":
35 +
			a.Alias = value
36 +
		case "published_date":
37 +
			a.PublishedDate = value
38 +
		case "description", "meta_description":
39 +
			a.MetaDescription = value
40 +
		case "meta_image":
41 +
			a.MetaImage = value
42 +
		case "lang":
43 +
			a.Lang = value
44 +
		case "tags":
45 +
			a.Tags = value
46 +
		case "status":
47 +
			a.Status = value
48 +
		}
49 +
	}
50 +
	return a
51 +
}
52 +
53 +
type parsedPageAttributes struct {
54 +
	Title       string
55 +
	Slug        string
56 +
	IsPublished bool
57 +
}
58 +
59 +
func parsePageAttributes(text string) parsedPageAttributes {
60 +
	var a parsedPageAttributes
61 +
	for _, line := range strings.Split(text, "\n") {
62 +
		i := strings.Index(line, ":")
63 +
		if i < 0 {
64 +
			continue
65 +
		}
66 +
		key := strings.ToLower(strings.TrimSpace(line[:i]))
67 +
		value := strings.TrimSpace(line[i+1:])
68 +
		switch key {
69 +
		case "title":
70 +
			a.Title = value
71 +
		case "slug":
72 +
			a.Slug = value
73 +
		case "published":
74 +
			a.IsPublished = value == "true"
75 +
		}
76 +
	}
77 +
	return a
78 +
}
79 +
80 +
func slugify(s string) string {
81 +
	var b strings.Builder
82 +
	for _, r := range strings.ToLower(s) {
83 +
		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
84 +
			b.WriteRune(r)
85 +
		} else {
86 +
			b.WriteByte('-')
87 +
		}
88 +
	}
89 +
	parts := strings.Split(b.String(), "-")
90 +
	out := parts[:0]
91 +
	for _, p := range parts {
92 +
		if p != "" {
93 +
			out = append(out, p)
94 +
		}
95 +
	}
96 +
	return strings.Join(out, "-")
97 +
}
98 +
99 +
func optStr(s string) *string {
100 +
	t := strings.TrimSpace(s)
101 +
	if t == "" {
102 +
		return nil
103 +
	}
104 +
	return &t
105 +
}
106 +
107 +
func deriveSlug(title, slug string) string {
108 +
	if slug != "" {
109 +
		return slug
110 +
	}
111 +
	if from := slugify(title); from != "" {
112 +
		return from
113 +
	}
114 +
	id, _ := generateID()
115 +
	return id
116 +
}
117 +
118 +
func generateID() (string, error) {
119 +
	// Imported from auth crate at call site; this is a fallback path.
120 +
	const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
121 +
	buf := make([]byte, 10)
122 +
	for i := range buf {
123 +
		buf[i] = alphabet[time.Now().UnixNano()%int64(len(alphabet))]
124 +
		time.Sleep(time.Nanosecond)
125 +
	}
126 +
	return string(buf), nil
127 +
}
128 +
129 +
var reservedPageSlugs = map[string]bool{
130 +
	"posts": true, "admin": true, "feed.xml": true,
131 +
	"custom-styles.css": true, "static": true, "files": true,
132 +
}
133 +
134 +
func isReservedPageSlug(slug string) bool {
135 +
	return reservedPageSlugs[slug]
136 +
}
137 +
138 +
func parseNavLinks(input string) []NavLink {
139 +
	var out []NavLink
140 +
	rest := input
141 +
	for {
142 +
		open := strings.Index(rest, "[")
143 +
		if open < 0 {
144 +
			break
145 +
		}
146 +
		close := strings.Index(rest[open:], "]")
147 +
		if close < 0 {
148 +
			break
149 +
		}
150 +
		close += open
151 +
		label := rest[open+1 : close]
152 +
		if close+1 >= len(rest) || rest[close+1] != '(' {
153 +
			rest = rest[close+1:]
154 +
			continue
155 +
		}
156 +
		urlEnd := strings.Index(rest[close+2:], ")")
157 +
		if urlEnd < 0 {
158 +
			break
159 +
		}
160 +
		urlEnd += close + 2
161 +
		url := rest[close+2 : urlEnd]
162 +
		if label != "" && url != "" {
163 +
			out = append(out, NavLink{Label: label, URL: url})
164 +
		}
165 +
		rest = rest[urlEnd+1:]
166 +
	}
167 +
	return out
168 +
}
169 +
170 +
func toRFC2822(sqliteTS string) string {
171 +
	if t, err := time.Parse("2006-01-02 15:04:05", sqliteTS); err == nil {
172 +
		return t.UTC().Format(time.RFC1123Z)
173 +
	}
174 +
	return sqliteTS
175 +
}
176 +
177 +
func xmlEscape(s string) string {
178 +
	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
179 +
	return r.Replace(s)
180 +
}
181 +
182 +
func mimeFromPath(path string) string {
183 +
	i := strings.LastIndex(path, ".")
184 +
	if i < 0 {
185 +
		return "application/octet-stream"
186 +
	}
187 +
	switch strings.ToLower(path[i+1:]) {
188 +
	case "css":
189 +
		return "text/css"
190 +
	case "js":
191 +
		return "application/javascript"
192 +
	case "html":
193 +
		return "text/html"
194 +
	case "png":
195 +
		return "image/png"
196 +
	case "jpg", "jpeg":
197 +
		return "image/jpeg"
198 +
	case "gif":
199 +
		return "image/gif"
200 +
	case "webp":
201 +
		return "image/webp"
202 +
	case "ico":
203 +
		return "image/x-icon"
204 +
	case "svg":
205 +
		return "image/svg+xml"
206 +
	case "woff", "woff2":
207 +
		return "font/woff2"
208 +
	case "ttf":
209 +
		return "font/ttf"
210 +
	case "otf":
211 +
		return "font/otf"
212 +
	case "json", "webmanifest":
213 +
		return "application/json"
214 +
	case "pdf":
215 +
		return "application/pdf"
216 +
	case "mp4":
217 +
		return "video/mp4"
218 +
	case "webm":
219 +
		return "video/webm"
220 +
	}
221 +
	return "application/octet-stream"
222 +
}
223 +
224 +
func postToMarkdown(p *Post) string {
225 +
	var b strings.Builder
226 +
	b.WriteString("---")
227 +
	if p.Title != nil {
228 +
		b.WriteString("\ntitle: " + *p.Title)
229 +
	}
230 +
	b.WriteString("\nslug: " + p.Slug)
231 +
	b.WriteString("\nstatus: " + p.Status)
232 +
	if p.PublishedDate != nil {
233 +
		b.WriteString("\npublished_date: " + *p.PublishedDate)
234 +
	}
235 +
	if p.Tags != nil {
236 +
		b.WriteString("\ntags: " + *p.Tags)
237 +
	}
238 +
	b.WriteString("\nlang: " + p.Lang)
239 +
	if p.Alias != nil {
240 +
		b.WriteString("\nalias: " + *p.Alias)
241 +
	}
242 +
	if p.MetaImage != nil {
243 +
		b.WriteString("\nmeta_image: " + *p.MetaImage)
244 +
	}
245 +
	if p.MetaDescription != nil {
246 +
		b.WriteString("\ndescription: " + *p.MetaDescription)
247 +
	}
248 +
	b.WriteString("\n---\n\n")
249 +
	b.WriteString(p.Content)
250 +
	return b.String()
251 +
}
252 +
253 +
func splitFrontmatter(content string) (string, string) {
254 +
	trimmed := strings.TrimPrefix(content, "\ufeff")
255 +
	var afterOpen string
256 +
	if strings.HasPrefix(trimmed, "---\n") {
257 +
		afterOpen = trimmed[4:]
258 +
	} else if strings.HasPrefix(trimmed, "---\r\n") {
259 +
		afterOpen = trimmed[5:]
260 +
	} else {
261 +
		return "", content
262 +
	}
263 +
	for _, sep := range []string{"\r\n---\r\n", "\r\n---\n", "\n---\r\n", "\n---\n"} {
264 +
		if i := strings.Index(afterOpen, sep); i >= 0 {
265 +
			body := afterOpen[i+len(sep):]
266 +
			body = strings.TrimLeft(body, "\r\n")
267 +
			return afterOpen[:i], body
268 +
		}
269 +
	}
270 +
	if strings.HasSuffix(afterOpen, "\n---") {
271 +
		return strings.TrimSuffix(afterOpen, "\n---"), ""
272 +
	}
273 +
	if strings.HasSuffix(afterOpen, "\r\n---") {
274 +
		return strings.TrimSuffix(afterOpen, "\r\n---"), ""
275 +
	}
276 +
	return "", content
277 +
}
278 +
279 +
func titleFromFilename(name string) string {
280 +
	stem := name
281 +
	if i := strings.LastIndex(name, "."); i > 0 {
282 +
		stem = name[:i]
283 +
	}
284 +
	cleaned := strings.Map(func(r rune) rune {
285 +
		if r == '-' || r == '_' {
286 +
			return ' '
287 +
		}
288 +
		return r
289 +
	}, stem)
290 +
	cleaned = strings.TrimSpace(cleaned)
291 +
	if cleaned == "" {
292 +
		return ""
293 +
	}
294 +
	return strings.ToUpper(cleaned[:1]) + cleaned[1:]
295 +
}
apps/shrink-go/.env.example (added) +2 −0
1 +
HOST=127.0.0.1
2 +
PORT=3000
apps/shrink-go/Dockerfile (added) +16 −0
1 +
# Build from repo root: docker build -t shrink-go -f apps/shrink-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/shrink-go/go.mod apps/shrink-go/go.sum ./apps/shrink-go/
6 +
WORKDIR /app/apps/shrink-go
7 +
RUN go mod download
8 +
COPY apps/shrink-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /shrink-go .
10 +
11 +
FROM debian:bookworm-slim
12 +
COPY --from=builder /shrink-go /usr/local/bin/shrink-go
13 +
ENV HOST=0.0.0.0
14 +
ENV PORT=3000
15 +
EXPOSE 3000
16 +
CMD ["shrink-go"]
apps/shrink-go/README.md (added) +30 −0
1 +
# shrink-go
2 +
3 +
Go rewrite of [shrink](../shrink). JPEG compression + resize via stdlib `image`
4 +
plus `golang.org/x/image/draw` for Catmull-Rom scaling.
5 +
6 +
## Quickstart
7 +
8 +
```bash
9 +
cp .env.example .env
10 +
go run .
11 +
```
12 +
13 +
### Environment Variables
14 +
15 +
| Variable | Default | Description |
16 +
|---|---|---|
17 +
| `HOST` | `127.0.0.1` | Bind host |
18 +
| `PORT` | `3000` | Server port |
19 +
20 +
## Routes
21 +
22 +
- `GET /` — upload UI
23 +
- `POST /compress` — multipart upload (`file`, `quality` 1-100, optional `width`)
24 +
- `GET /static/*` — embedded assets
25 +
- `/assets/*` — darkmatter css/fonts
26 +
27 +
## Notes vs Rust version
28 +
29 +
EXIF reinjection is dropped; standard JPEG encoder is used. Output is a fresh
30 +
JPEG without metadata. The 20 MB upload limit is preserved.
apps/shrink-go/app.go (added) +15 −0
1 +
package main
2 +
3 +
import (
4 +
	"embed"
5 +
	"html/template"
6 +
	"log/slog"
7 +
)
8 +
9 +
//go:embed templates/*.html static/*
10 +
var appFS embed.FS
11 +
12 +
type App struct {
13 +
	Log       *slog.Logger
14 +
	Templates *template.Template
15 +
}
apps/shrink-go/docker-compose.yml (added) +11 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/shrink-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
    restart: unless-stopped
apps/shrink-go/go.mod (added) +16 −0
1 +
module github.com/stevedylandev/andromeda/apps/shrink-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
7 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
9 +
	golang.org/x/image v0.27.0
10 +
)
11 +
12 +
replace (
13 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
14 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
15 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
16 +
)
apps/shrink-go/go.sum (added) +2 −0
1 +
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
2 +
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
apps/shrink-go/handlers.go (added) +62 −0
1 +
package main
2 +
3 +
import (
4 +
	"io"
5 +
	"net/http"
6 +
	"strconv"
7 +
8 +
	"github.com/stevedylandev/andromeda/crates-go/web"
9 +
)
10 +
11 +
const maxUploadBytes = 20 * 1024 * 1024
12 +
13 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
14 +
	web.Render(a.Templates, w, "index.html", nil, a.Log)
15 +
}
16 +
17 +
func (a *App) compressHandler(w http.ResponseWriter, r *http.Request) {
18 +
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
19 +
	if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
20 +
		http.Error(w, "Failed to read upload: "+err.Error(), http.StatusBadRequest)
21 +
		return
22 +
	}
23 +
24 +
	file, header, err := r.FormFile("file")
25 +
	if err != nil {
26 +
		http.Error(w, "No file provided", http.StatusBadRequest)
27 +
		return
28 +
	}
29 +
	defer file.Close()
30 +
	data, err := io.ReadAll(file)
31 +
	if err != nil {
32 +
		http.Error(w, "Failed to read file: "+err.Error(), http.StatusBadRequest)
33 +
		return
34 +
	}
35 +
36 +
	quality := 80
37 +
	if v := r.FormValue("quality"); v != "" {
38 +
		if q, err := strconv.Atoi(v); err == nil {
39 +
			quality = q
40 +
		}
41 +
	}
42 +
	width := 0
43 +
	if v := r.FormValue("width"); v != "" {
44 +
		if wv, err := strconv.Atoi(v); err == nil && wv > 0 {
45 +
			width = wv
46 +
		}
47 +
	}
48 +
49 +
	out, err := compressImage(data, quality, width)
50 +
	if err != nil {
51 +
		http.Error(w, "Compression failed: "+err.Error(), http.StatusInternalServerError)
52 +
		return
53 +
	}
54 +
55 +
	name := "image"
56 +
	if header != nil && header.Filename != "" {
57 +
		name = header.Filename
58 +
	}
59 +
	w.Header().Set("Content-Type", "image/jpeg")
60 +
	w.Header().Set("Content-Disposition", `attachment; filename="`+buildDownloadFilename(name, "jpg")+`"`)
61 +
	_, _ = w.Write(out)
62 +
}
apps/shrink-go/image.go (added) +48 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"fmt"
6 +
	"image"
7 +
	"image/jpeg"
8 +
	_ "image/png"
9 +
	"path/filepath"
10 +
	"strings"
11 +
12 +
	"golang.org/x/image/draw"
13 +
)
14 +
15 +
func compressImage(data []byte, quality int, width int) ([]byte, error) {
16 +
	img, _, err := image.Decode(bytes.NewReader(data))
17 +
	if err != nil {
18 +
		return nil, fmt.Errorf("Failed to decode image: %w", err)
19 +
	}
20 +
	if width > 0 && width != img.Bounds().Dx() {
21 +
		src := img
22 +
		bounds := src.Bounds()
23 +
		aspect := float64(bounds.Dy()) / float64(bounds.Dx())
24 +
		height := int(float64(width)*aspect + 0.5)
25 +
		dst := image.NewRGBA(image.Rect(0, 0, width, height))
26 +
		draw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
27 +
		img = dst
28 +
	}
29 +
	if quality < 1 {
30 +
		quality = 1
31 +
	}
32 +
	if quality > 100 {
33 +
		quality = 100
34 +
	}
35 +
	var out bytes.Buffer
36 +
	if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: quality}); err != nil {
37 +
		return nil, fmt.Errorf("JPEG encoding failed: %w", err)
38 +
	}
39 +
	return out.Bytes(), nil
40 +
}
41 +
42 +
func buildDownloadFilename(original, newExt string) string {
43 +
	stem := strings.TrimSuffix(filepath.Base(original), filepath.Ext(filepath.Base(original)))
44 +
	if stem == "" {
45 +
		stem = "compressed"
46 +
	}
47 +
	return stem + "_compressed." + newExt
48 +
}
apps/shrink-go/main.go (added) +24 −0
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/crates-go/config"
11 +
)
12 +
13 +
func main() {
14 +
	config.LoadDotEnv(".env")
15 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
16 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
17 +
	app := &App{Log: logger, Templates: tmpl}
18 +
19 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
20 +
	logger.Info("shrink-go server running", "addr", addr)
21 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
22 +
		log.Fatal(err)
23 +
	}
24 +
}
apps/shrink-go/routes.go (added) +17 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
7 +
	"github.com/stevedylandev/andromeda/crates-go/web"
8 +
)
9 +
10 +
func (a *App) routes() *http.ServeMux {
11 +
	mux := http.NewServeMux()
12 +
	mux.HandleFunc("GET /", a.indexHandler)
13 +
	mux.HandleFunc("POST /compress", a.compressHandler)
14 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
15 +
	darkmatter.Mount(mux, "/assets")
16 +
	return mux
17 +
}
apps/shrink-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/shrink-go/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/shrink-go/static/icon.png (added) +0 −0

Binary file — no preview.

apps/shrink-go/static/og.png (added) +0 −0

Binary file — no preview.

apps/shrink-go/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/shrink-go/static/styles.css (added) +183 −0
1 +
/* shrink — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
/* Drop Zone */
6 +
#drop-zone {
7 +
    border: 1px solid rgba(255, 255, 255, 0.3);
8 +
    padding: 3rem 2rem;
9 +
    text-align: center;
10 +
    cursor: pointer;
11 +
    display: flex;
12 +
    align-items: center;
13 +
    justify-content: center;
14 +
    min-height: 150px;
15 +
    transition: border-color 0.15s;
16 +
}
17 +
18 +
#drop-zone:hover,
19 +
#drop-zone.drag-over {
20 +
    border-color: #ffffff;
21 +
}
22 +
23 +
#drop-zone p {
24 +
    opacity: 0.5;
25 +
    font-size: 14px;
26 +
    text-transform: uppercase;
27 +
    letter-spacing: 0.1em;
28 +
}
29 +
30 +
/* Preview */
31 +
#preview-section {
32 +
    display: none;
33 +
    flex-direction: column;
34 +
    gap: 0.5rem;
35 +
}
36 +
37 +
#preview-section.visible {
38 +
    display: flex;
39 +
}
40 +
41 +
#preview-img {
42 +
    max-width: 100%;
43 +
    max-height: 300px;
44 +
    object-fit: contain;
45 +
    border: 1px solid #333;
46 +
}
47 +
48 +
#original-size {
49 +
    opacity: 0.7;
50 +
    font-size: 12px;
51 +
    text-transform: uppercase;
52 +
}
53 +
54 +
/* Controls */
55 +
#controls {
56 +
    display: none;
57 +
    flex-direction: column;
58 +
    gap: 1rem;
59 +
}
60 +
61 +
#controls.visible {
62 +
    display: flex;
63 +
}
64 +
65 +
.control-row {
66 +
    display: flex;
67 +
    align-items: center;
68 +
    gap: 1rem;
69 +
}
70 +
71 +
.control-row label {
72 +
    font-size: 12px;
73 +
    opacity: 0.7;
74 +
    text-transform: uppercase;
75 +
    min-width: 80px;
76 +
}
77 +
78 +
#quality-value {
79 +
    font-size: 14px;
80 +
    min-width: 2.5em;
81 +
    text-align: right;
82 +
}
83 +
84 +
/* Number Inputs */
85 +
input[type="number"] {
86 +
    width: 90px;
87 +
    -moz-appearance: textfield;
88 +
}
89 +
90 +
input[type="number"]::-webkit-inner-spin-button,
91 +
input[type="number"]::-webkit-outer-spin-button {
92 +
    -webkit-appearance: none;
93 +
}
94 +
95 +
.dimension-sep {
96 +
    opacity: 0.5;
97 +
}
98 +
99 +
#height-display {
100 +
    opacity: 0.7;
101 +
    min-width: 4em;
102 +
}
103 +
104 +
/* Range Input */
105 +
input[type="range"] {
106 +
    -webkit-appearance: none;
107 +
    appearance: none;
108 +
    flex: 1;
109 +
    height: 1px;
110 +
    background: rgba(255, 255, 255, 0.3);
111 +
    outline: none;
112 +
    border-radius: 0;
113 +
}
114 +
115 +
input[type="range"]::-webkit-slider-thumb {
116 +
    -webkit-appearance: none;
117 +
    appearance: none;
118 +
    width: 14px;
119 +
    height: 14px;
120 +
    background: #ffffff;
121 +
    border: none;
122 +
    border-radius: 0;
123 +
    cursor: pointer;
124 +
}
125 +
126 +
input[type="range"]::-moz-range-thumb {
127 +
    width: 14px;
128 +
    height: 14px;
129 +
    background: #ffffff;
130 +
    border: none;
131 +
    border-radius: 0;
132 +
    cursor: pointer;
133 +
}
134 +
135 +
input[type="range"].disabled {
136 +
    opacity: 0.3;
137 +
}
138 +
139 +
/* Results */
140 +
#result-section {
141 +
    display: none;
142 +
    flex-direction: column;
143 +
    gap: 1.5rem;
144 +
    padding-top: 1.5rem;
145 +
    border-top: 1px solid #333;
146 +
}
147 +
148 +
#result-section.visible {
149 +
    display: flex;
150 +
}
151 +
152 +
.size-comparison {
153 +
    display: flex;
154 +
    gap: 2rem;
155 +
}
156 +
157 +
.size-comparison label {
158 +
    font-size: 12px;
159 +
    opacity: 0.5;
160 +
    text-transform: uppercase;
161 +
    display: block;
162 +
    margin-bottom: 0.25rem;
163 +
}
164 +
165 +
.size-comparison p {
166 +
    font-size: 16px;
167 +
}
168 +
169 +
#download-link {
170 +
    text-decoration: none;
171 +
    align-self: flex-start;
172 +
}
173 +
174 +
button {
175 +
    text-transform: uppercase;
176 +
    letter-spacing: 0.05em;
177 +
    padding: 0.6rem 1.5rem;
178 +
}
179 +
180 +
button:disabled {
181 +
    opacity: 0.3;
182 +
    cursor: default;
183 +
}
apps/shrink-go/templates/base.html (added) +28 −0
1 +
{{define "base.html"}}<!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 +
    <title>{{block "title" .}}Shrink{{end}}</title>
8 +
    <meta name="description" content="Compress and resize images">
9 +
    <meta property="og:title" content="SHRINK">
10 +
    <meta property="og:description" content="Compress and resize images">
11 +
    <meta property="og:image" content="/static/og.png">
12 +
    <meta property="og:type" content="website">
13 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
14 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
15 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
16 +
    <link rel="manifest" href="/static/site.webmanifest">
17 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
18 +
    <link rel="stylesheet" href="/static/styles.css">
19 +
</head>
20 +
<body>
21 +
    <div class="header">
22 +
        <a href="/" class="logo">SHRINK</a>
23 +
    </div>
24 +
    <main>
25 +
        {{block "content" .}}{{end}}
26 +
    </main>
27 +
</body>
28 +
</html>{{end}}
apps/shrink-go/templates/index.html (added) +171 −0
1 +
{{define "index.html"}}{{template "base.html" .}}{{end}}
2 +
{{define "title"}}SHRINK{{end}}
3 +
{{define "content"}}
4 +
5 +
<div id="drop-zone">
6 +
    <p>DROP IMAGE HERE OR CLICK TO SELECT</p>
7 +
    <input type="file" id="file-input" accept="image/*" hidden>
8 +
</div>
9 +
10 +
<div id="preview-section">
11 +
    <img id="preview-img" alt="Preview">
12 +
    <p id="original-size"></p>
13 +
</div>
14 +
15 +
<div id="controls">
16 +
    <div class="control-row">
17 +
        <label for="quality-slider">QUALITY</label>
18 +
        <input type="range" id="quality-slider" min="1" max="100" value="80">
19 +
        <span id="quality-value">80</span>
20 +
    </div>
21 +
    <div class="control-row">
22 +
        <label>RESIZE</label>
23 +
        <input type="number" id="width-input" placeholder="WIDTH" min="1">
24 +
        <span class="dimension-sep">x</span>
25 +
        <span id="height-display">—</span>
26 +
    </div>
27 +
    <button id="compress-btn">COMPRESS</button>
28 +
</div>
29 +
30 +
<div id="result-section">
31 +
    <div class="size-comparison">
32 +
        <div>
33 +
            <label>ORIGINAL</label>
34 +
            <p id="result-original-size"></p>
35 +
        </div>
36 +
        <div>
37 +
            <label>COMPRESSED</label>
38 +
            <p id="result-compressed-size"></p>
39 +
        </div>
40 +
        <div>
41 +
            <label>REDUCTION</label>
42 +
            <p id="result-reduction"></p>
43 +
        </div>
44 +
    </div>
45 +
    <a id="download-link">
46 +
        <button>DOWNLOAD</button>
47 +
    </a>
48 +
</div>
49 +
50 +
<script>
51 +
const dropZone = document.getElementById('drop-zone');
52 +
const fileInput = document.getElementById('file-input');
53 +
const previewSection = document.getElementById('preview-section');
54 +
const previewImg = document.getElementById('preview-img');
55 +
const originalSize = document.getElementById('original-size');
56 +
const controls = document.getElementById('controls');
57 +
const qualitySlider = document.getElementById('quality-slider');
58 +
const qualityValue = document.getElementById('quality-value');
59 +
const widthInput = document.getElementById('width-input');
60 +
const heightDisplay = document.getElementById('height-display');
61 +
const compressBtn = document.getElementById('compress-btn');
62 +
const resultSection = document.getElementById('result-section');
63 +
const resultOriginalSize = document.getElementById('result-original-size');
64 +
const resultCompressedSize = document.getElementById('result-compressed-size');
65 +
const resultReduction = document.getElementById('result-reduction');
66 +
const downloadLink = document.getElementById('download-link');
67 +
68 +
let selectedFile = null;
69 +
let naturalWidth = 0;
70 +
let naturalHeight = 0;
71 +
72 +
function formatBytes(bytes) {
73 +
    if (bytes < 1024) return bytes + ' B';
74 +
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
75 +
    return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
76 +
}
77 +
78 +
function handleFile(file) {
79 +
    if (!file || !file.type.startsWith('image/')) return;
80 +
    selectedFile = file;
81 +
    const url = URL.createObjectURL(file);
82 +
    previewImg.src = url;
83 +
    originalSize.textContent = formatBytes(file.size);
84 +
    previewSection.classList.add('visible');
85 +
    controls.classList.add('visible');
86 +
    resultSection.classList.remove('visible');
87 +
88 +
    const tmp = new Image();
89 +
    tmp.onload = () => {
90 +
        naturalWidth = tmp.naturalWidth;
91 +
        naturalHeight = tmp.naturalHeight;
92 +
        widthInput.value = naturalWidth;
93 +
        heightDisplay.textContent = naturalHeight;
94 +
    };
95 +
    tmp.src = url;
96 +
}
97 +
98 +
dropZone.addEventListener('click', () => fileInput.click());
99 +
fileInput.addEventListener('change', () => {
100 +
    if (fileInput.files.length) handleFile(fileInput.files[0]);
101 +
});
102 +
103 +
dropZone.addEventListener('dragover', (e) => {
104 +
    e.preventDefault();
105 +
    dropZone.classList.add('drag-over');
106 +
});
107 +
dropZone.addEventListener('dragleave', () => {
108 +
    dropZone.classList.remove('drag-over');
109 +
});
110 +
dropZone.addEventListener('drop', (e) => {
111 +
    e.preventDefault();
112 +
    dropZone.classList.remove('drag-over');
113 +
    if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
114 +
});
115 +
116 +
qualitySlider.addEventListener('input', () => {
117 +
    qualityValue.textContent = qualitySlider.value;
118 +
});
119 +
120 +
widthInput.addEventListener('input', () => {
121 +
    if (naturalWidth > 0) {
122 +
        const w = parseInt(widthInput.value) || 0;
123 +
        heightDisplay.textContent = w > 0 ? Math.round(w * naturalHeight / naturalWidth) : '—';
124 +
    }
125 +
});
126 +
127 +
compressBtn.addEventListener('click', async () => {
128 +
    if (!selectedFile) return;
129 +
    compressBtn.disabled = true;
130 +
    compressBtn.textContent = 'COMPRESSING...';
131 +
132 +
    const formData = new FormData();
133 +
    formData.append('file', selectedFile);
134 +
    formData.append('quality', qualitySlider.value);
135 +
    const w = parseInt(widthInput.value) || 0;
136 +
    if (w > 0) {
137 +
        formData.append('width', w.toString());
138 +
    }
139 +
140 +
    try {
141 +
        const res = await fetch('/compress', { method: 'POST', body: formData });
142 +
        if (!res.ok) {
143 +
            const text = await res.text();
144 +
            alert('Compression failed: ' + text);
145 +
            return;
146 +
        }
147 +
        const blob = await res.blob();
148 +
        const originalBytes = selectedFile.size;
149 +
        const compressedBytes = blob.size;
150 +
        const reduction = ((1 - compressedBytes / originalBytes) * 100).toFixed(1);
151 +
152 +
        resultOriginalSize.textContent = formatBytes(originalBytes);
153 +
        resultCompressedSize.textContent = formatBytes(compressedBytes);
154 +
        resultReduction.textContent = reduction + '%';
155 +
156 +
        if (downloadLink.href && downloadLink.href.startsWith('blob:')) {
157 +
            URL.revokeObjectURL(downloadLink.href);
158 +
        }
159 +
        downloadLink.href = URL.createObjectURL(blob);
160 +
        downloadLink.download = selectedFile.name.replace(/\.[^.]+$/, '') + '_compressed.jpg';
161 +
        resultSection.classList.add('visible');
162 +
    } catch (err) {
163 +
        alert('Error: ' + err.message);
164 +
    } finally {
165 +
        compressBtn.disabled = false;
166 +
        compressBtn.textContent = 'COMPRESS';
167 +
    }
168 +
});
169 +
</script>
170 +
171 +
{{end}}
apps/sipp-go/.env.example (added) +8 −0
1 +
SIPP_API_KEY=your-secret-key
2 +
SIPP_AUTH_ENDPOINTS=api_delete,api_list,api_update
3 +
SIPP_DB_PATH=sipp.sqlite
4 +
SIPP_MAX_CONTENT_SIZE=512000
5 +
SIPP_REMOTE_URL=http://your-server.com
6 +
BASE_URL=http://localhost:3000
7 +
HOST=127.0.0.1
8 +
PORT=3000
apps/sipp-go/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t sipp-go -f apps/sipp-go/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY crates-go/ ./crates-go/
5 +
COPY apps/sipp-go/go.mod apps/sipp-go/go.sum ./apps/sipp-go/
6 +
WORKDIR /app/apps/sipp-go
7 +
RUN go mod download
8 +
COPY apps/sipp-go/ ./
9 +
RUN CGO_ENABLED=0 go build -o /sipp-server ./cmd/server
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 /sipp-server /usr/local/bin/sipp-server
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
EXPOSE 3000
18 +
CMD ["sipp-server"]
apps/sipp-go/README.md (added) +35 −0
1 +
# sipp-go
2 +
3 +
Go rewrite of [sipp](../sipp). Two binaries:
4 +
5 +
- `cmd/server` — web server only (HTTP + admin + API + syntax highlight via
6 +
  `github.com/alecthomas/chroma/v2`).
7 +
- `cmd/sipp` — CLI dispatcher: `sipp server`, or `sipp <file>` to upload a
8 +
  snippet to a remote instance via the JSON API.
9 +
10 +
## Notes vs Rust version
11 +
12 +
- **Interactive TUI not ported.** The Rust binary uses `ratatui` +
13 +
  `crossterm`; build with the Rust version if you need it.
14 +
- Syntax highlighting uses Chroma (replaces syntect). The darkmatter
15 +
  `.tmTheme` is not reused; Chroma's `monokai` style ships by default.
16 +
- Snippet schema and routes match the Rust app; existing SQLite files are
17 +
  compatible.
18 +
19 +
## Quickstart
20 +
21 +
```bash
22 +
cp .env.example .env
23 +
go run ./cmd/server
24 +
# or
25 +
go run ./cmd/sipp server --port 3000
26 +
```
27 +
28 +
Upload a file:
29 +
30 +
```bash
31 +
SIPP_REMOTE_URL=http://localhost:3000 SIPP_API_KEY=$KEY \
32 +
  go run ./cmd/sipp ./path/to/file.go
33 +
```
34 +
35 +
See `.env.example` for env vars.
apps/sipp-go/cmd/server/main.go (added) +17 −0
1 +
package main
2 +
3 +
import (
4 +
	"log"
5 +
6 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/server"
7 +
	"github.com/stevedylandev/andromeda/crates-go/config"
8 +
)
9 +
10 +
func main() {
11 +
	config.LoadDotEnv(".env")
12 +
	host := config.Getenv("HOST", "127.0.0.1")
13 +
	port := config.GetenvInt("PORT", 3000)
14 +
	if err := server.Run(host, port); err != nil {
15 +
		log.Fatal(err)
16 +
	}
17 +
}
apps/sipp-go/cmd/sipp/main.go (added) +144 −0
1 +
// Sipp CLI: minimal command dispatcher.
2 +
//
3 +
//	sipp server              start the web server
4 +
//	sipp [-r URL] [-k KEY] <file>   upload a file to a remote sipp server
5 +
//	sipp --help
6 +
//
7 +
// The interactive TUI from the Rust version is not ported.
8 +
package main
9 +
10 +
import (
11 +
	"bytes"
12 +
	"encoding/json"
13 +
	"fmt"
14 +
	"io"
15 +
	"net/http"
16 +
	"os"
17 +
	"path/filepath"
18 +
	"strconv"
19 +
	"strings"
20 +
21 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/server"
22 +
	"github.com/stevedylandev/andromeda/crates-go/config"
23 +
)
24 +
25 +
const usage = `sipp — minimal code sharing CLI
26 +
27 +
usage:
28 +
  sipp server [--host HOST] [--port PORT]
29 +
  sipp [-r URL] [-k KEY] <file>     create a snippet from FILE on the remote server
30 +
  sipp --help
31 +
32 +
env:
33 +
  SIPP_REMOTE_URL  default remote URL
34 +
  SIPP_API_KEY     API key used for authenticated requests
35 +
`
36 +
37 +
func main() {
38 +
	config.LoadDotEnv(".env")
39 +
	args := os.Args[1:]
40 +
	if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
41 +
		fmt.Print(usage)
42 +
		return
43 +
	}
44 +
	switch args[0] {
45 +
	case "server":
46 +
		runServer(args[1:])
47 +
	default:
48 +
		runUpload(args)
49 +
	}
50 +
}
51 +
52 +
func runServer(args []string) {
53 +
	host := config.Getenv("HOST", "127.0.0.1")
54 +
	port := config.GetenvInt("PORT", 3000)
55 +
	for i := 0; i < len(args); i++ {
56 +
		switch args[i] {
57 +
		case "--host":
58 +
			if i+1 < len(args) {
59 +
				host = args[i+1]
60 +
				i++
61 +
			}
62 +
		case "--port", "-p":
63 +
			if i+1 < len(args) {
64 +
				if n, err := strconv.Atoi(args[i+1]); err == nil {
65 +
					port = n
66 +
				}
67 +
				i++
68 +
			}
69 +
		}
70 +
	}
71 +
	if err := server.Run(host, port); err != nil {
72 +
		fmt.Fprintln(os.Stderr, err)
73 +
		os.Exit(1)
74 +
	}
75 +
}
76 +
77 +
func runUpload(args []string) {
78 +
	remote := os.Getenv("SIPP_REMOTE_URL")
79 +
	apiKey := os.Getenv("SIPP_API_KEY")
80 +
	var file string
81 +
	for i := 0; i < len(args); i++ {
82 +
		switch args[i] {
83 +
		case "-r", "--remote":
84 +
			if i+1 < len(args) {
85 +
				remote = args[i+1]
86 +
				i++
87 +
			}
88 +
		case "-k", "--api-key":
89 +
			if i+1 < len(args) {
90 +
				apiKey = args[i+1]
91 +
				i++
92 +
			}
93 +
		default:
94 +
			if !strings.HasPrefix(args[i], "-") {
95 +
				file = args[i]
96 +
			}
97 +
		}
98 +
	}
99 +
	if file == "" {
100 +
		fmt.Fprintln(os.Stderr, "no file specified")
101 +
		fmt.Fprint(os.Stderr, usage)
102 +
		os.Exit(2)
103 +
	}
104 +
	if remote == "" {
105 +
		fmt.Fprintln(os.Stderr, "remote URL not set (use -r or SIPP_REMOTE_URL)")
106 +
		os.Exit(2)
107 +
	}
108 +
109 +
	data, err := os.ReadFile(file)
110 +
	if err != nil {
111 +
		fmt.Fprintln(os.Stderr, err)
112 +
		os.Exit(1)
113 +
	}
114 +
	body, _ := json.Marshal(map[string]string{
115 +
		"name":    filepath.Base(file),
116 +
		"content": string(data),
117 +
	})
118 +
	req, err := http.NewRequest(http.MethodPost, strings.TrimRight(remote, "/")+"/api/snippets", bytes.NewReader(body))
119 +
	if err != nil {
120 +
		fmt.Fprintln(os.Stderr, err)
121 +
		os.Exit(1)
122 +
	}
123 +
	req.Header.Set("Content-Type", "application/json")
124 +
	if apiKey != "" {
125 +
		req.Header.Set("x-api-key", apiKey)
126 +
	}
127 +
	resp, err := http.DefaultClient.Do(req)
128 +
	if err != nil {
129 +
		fmt.Fprintln(os.Stderr, err)
130 +
		os.Exit(1)
131 +
	}
132 +
	defer resp.Body.Close()
133 +
	respBody, _ := io.ReadAll(resp.Body)
134 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
135 +
		fmt.Fprintf(os.Stderr, "server returned %s: %s\n", resp.Status, string(respBody))
136 +
		os.Exit(1)
137 +
	}
138 +
	var s server.Snippet
139 +
	if err := json.Unmarshal(respBody, &s); err != nil {
140 +
		fmt.Fprintln(os.Stderr, "could not parse response:", err)
141 +
		os.Exit(1)
142 +
	}
143 +
	fmt.Println(strings.TrimRight(remote, "/") + "/s/" + s.ShortID)
144 +
}
apps/sipp-go/docker-compose.yml (added) +21 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/sipp-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - SIPP_DB_PATH=/data/sipp-go.sqlite
12 +
      - SIPP_API_KEY=${SIPP_API_KEY:-}
13 +
      - SIPP_AUTH_ENDPOINTS=${SIPP_AUTH_ENDPOINTS:-api_delete,api_list,api_update}
14 +
      - SIPP_MAX_CONTENT_SIZE=${SIPP_MAX_CONTENT_SIZE:-512000}
15 +
      - BASE_URL=${BASE_URL:-http://localhost:3000}
16 +
    volumes:
17 +
      - sipp-go-data:/data
18 +
    restart: unless-stopped
19 +
20 +
volumes:
21 +
  sipp-go-data:
apps/sipp-go/go.mod (added) +34 −0
1 +
module github.com/stevedylandev/andromeda/apps/sipp-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/alecthomas/chroma/v2 v2.14.0
7 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
11 +
	modernc.org/sqlite v1.37.1
12 +
)
13 +
14 +
require (
15 +
	github.com/dlclark/regexp2 v1.11.0 // indirect
16 +
	github.com/dustin/go-humanize v1.0.1 // indirect
17 +
	github.com/google/uuid v1.6.0 // indirect
18 +
	github.com/mattn/go-isatty v0.0.20 // indirect
19 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
20 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
21 +
	golang.org/x/crypto v0.39.0 // indirect
22 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
23 +
	golang.org/x/sys v0.33.0 // indirect
24 +
	modernc.org/libc v1.65.7 // indirect
25 +
	modernc.org/mathutil v1.7.1 // indirect
26 +
	modernc.org/memory v1.11.0 // indirect
27 +
)
28 +
29 +
replace (
30 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
31 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
32 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
33 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
34 +
)
apps/sipp-go/go.sum (added) +59 −0
1 +
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
2 +
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
3 +
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
4 +
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
5 +
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
6 +
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
7 +
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
8 +
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
9 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
10 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
11 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
12 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
13 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
14 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
15 +
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
16 +
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
17 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
18 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
19 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
20 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
21 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
22 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
23 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
24 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
25 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
26 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
27 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
28 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
29 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
30 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
31 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
32 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
33 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
34 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
35 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
36 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
37 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
38 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
39 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
40 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
41 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
42 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
43 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
44 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
45 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
46 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
47 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
48 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
49 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
50 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
51 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
52 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
53 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
54 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
55 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
56 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
57 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
58 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
59 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/sipp-go/server/server.go (added) +494 −0
1 +
// Package server hosts the sipp web server, API, and admin pages.
2 +
package server
3 +
4 +
import (
5 +
	"bytes"
6 +
	"database/sql"
7 +
	"embed"
8 +
	"encoding/json"
9 +
	"errors"
10 +
	"html/template"
11 +
	"io"
12 +
	"log"
13 +
	"log/slog"
14 +
	"net/http"
15 +
	"net/url"
16 +
	"os"
17 +
	"strconv"
18 +
	"strings"
19 +
20 +
	"github.com/alecthomas/chroma/v2"
21 +
	"github.com/alecthomas/chroma/v2/formatters/html"
22 +
	"github.com/alecthomas/chroma/v2/lexers"
23 +
	"github.com/alecthomas/chroma/v2/styles"
24 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
25 +
	"github.com/stevedylandev/andromeda/crates-go/config"
26 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
27 +
	"github.com/stevedylandev/andromeda/crates-go/web"
28 +
	_ "modernc.org/sqlite"
29 +
)
30 +
31 +
//go:embed templates/*.html static/*
32 +
var appFS embed.FS
33 +
34 +
type Snippet struct {
35 +
	ID      int64  `json:"id"`
36 +
	ShortID string `json:"short_id"`
37 +
	Content string `json:"content"`
38 +
	Name    string `json:"name"`
39 +
}
40 +
41 +
type App struct {
42 +
	DB              *sql.DB
43 +
	Log             *slog.Logger
44 +
	Templates       *template.Template
45 +
	Sessions        *auth.Store
46 +
	APIKey          string
47 +
	BaseURL         string
48 +
	CookieSecure    bool
49 +
	AuthEndpoints   map[string]bool
50 +
	MaxContentSize  int
51 +
}
52 +
53 +
const schema = `
54 +
CREATE TABLE IF NOT EXISTS snippets (
55 +
    id INTEGER PRIMARY KEY AUTOINCREMENT,
56 +
    short_id TEXT NOT NULL UNIQUE,
57 +
    content TEXT NOT NULL,
58 +
    name TEXT NOT NULL
59 +
);
60 +
`
61 +
62 +
func openDB(path string) (*sql.DB, error) {
63 +
	db, err := sql.Open("sqlite", path)
64 +
	if err != nil {
65 +
		return nil, err
66 +
	}
67 +
	db.SetMaxOpenConns(1)
68 +
	db.SetMaxIdleConns(1)
69 +
	if _, err := db.Exec(schema); err != nil {
70 +
		return nil, err
71 +
	}
72 +
	return db, nil
73 +
}
74 +
75 +
func scanSnippet(s interface{ Scan(...any) error }) (*Snippet, error) {
76 +
	var sn Snippet
77 +
	err := s.Scan(&sn.ID, &sn.ShortID, &sn.Content, &sn.Name)
78 +
	if errors.Is(err, sql.ErrNoRows) {
79 +
		return nil, nil
80 +
	}
81 +
	if err != nil {
82 +
		return nil, err
83 +
	}
84 +
	return &sn, nil
85 +
}
86 +
87 +
func createSnippet(db *sql.DB, name, content string) (*Snippet, error) {
88 +
	shortID, err := auth.GenerateShortID(10)
89 +
	if err != nil {
90 +
		return nil, err
91 +
	}
92 +
	res, err := db.Exec(`INSERT INTO snippets (short_id, content, name) VALUES (?, ?, ?)`, shortID, content, name)
93 +
	if err != nil {
94 +
		return nil, err
95 +
	}
96 +
	id, _ := res.LastInsertId()
97 +
	return &Snippet{ID: id, ShortID: shortID, Content: content, Name: name}, nil
98 +
}
99 +
100 +
func getSnippetByShortID(db *sql.DB, shortID string) (*Snippet, error) {
101 +
	return scanSnippet(db.QueryRow(`SELECT id, short_id, content, name FROM snippets WHERE short_id = ?`, shortID))
102 +
}
103 +
104 +
func getAllSnippets(db *sql.DB) ([]Snippet, error) {
105 +
	rows, err := db.Query(`SELECT id, short_id, content, name FROM snippets ORDER BY id DESC`)
106 +
	if err != nil {
107 +
		return nil, err
108 +
	}
109 +
	defer rows.Close()
110 +
	out := []Snippet{}
111 +
	for rows.Next() {
112 +
		s, err := scanSnippet(rows)
113 +
		if err != nil {
114 +
			return nil, err
115 +
		}
116 +
		out = append(out, *s)
117 +
	}
118 +
	return out, rows.Err()
119 +
}
120 +
121 +
func deleteSnippetByShortID(db *sql.DB, shortID string) (bool, error) {
122 +
	res, err := db.Exec(`DELETE FROM snippets WHERE short_id = ?`, shortID)
123 +
	if err != nil {
124 +
		return false, err
125 +
	}
126 +
	n, _ := res.RowsAffected()
127 +
	return n > 0, nil
128 +
}
129 +
130 +
func updateSnippetByShortID(db *sql.DB, shortID, name, content string) (*Snippet, error) {
131 +
	res, err := db.Exec(`UPDATE snippets SET name = ?, content = ? WHERE short_id = ?`, name, content, shortID)
132 +
	if err != nil {
133 +
		return nil, err
134 +
	}
135 +
	if n, _ := res.RowsAffected(); n == 0 {
136 +
		return nil, nil
137 +
	}
138 +
	return getSnippetByShortID(db, shortID)
139 +
}
140 +
141 +
func highlight(name, content string) string {
142 +
	ext := ""
143 +
	if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 {
144 +
		ext = strings.ToLower(name[i+1:])
145 +
	}
146 +
	switch ext {
147 +
	case "ts", "tsx", "jsx":
148 +
		ext = "js"
149 +
	}
150 +
	var lexer chroma.Lexer
151 +
	if ext != "" {
152 +
		lexer = lexers.MatchMimeType("text/" + ext)
153 +
		if lexer == nil {
154 +
			lexer = lexers.Get(ext)
155 +
		}
156 +
	}
157 +
	if lexer == nil {
158 +
		lexer = lexers.Analyse(content)
159 +
	}
160 +
	if lexer == nil {
161 +
		lexer = lexers.Fallback
162 +
	}
163 +
	style := styles.Get("monokai")
164 +
	if style == nil {
165 +
		style = styles.Fallback
166 +
	}
167 +
	formatter := html.New(html.Standalone(false), html.WithClasses(false))
168 +
	iterator, err := lexer.Tokenise(nil, content)
169 +
	if err != nil {
170 +
		escaped := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(content)
171 +
		return "<pre>" + escaped + "</pre>"
172 +
	}
173 +
	var buf bytes.Buffer
174 +
	if err := formatter.Format(&buf, style, iterator); err != nil {
175 +
		escaped := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(content)
176 +
		return "<pre>" + escaped + "</pre>"
177 +
	}
178 +
	return buf.String()
179 +
}
180 +
181 +
type indexPageData struct{ BaseURL string }
182 +
type adminPageData struct {
183 +
	BaseURL  string
184 +
	Snippets []Snippet
185 +
}
186 +
type loginPageData struct {
187 +
	Error string
188 +
	Next  string
189 +
}
190 +
type snippetPageData struct {
191 +
	BaseURL            string
192 +
	Name               string
193 +
	Content            string
194 +
	HighlightedContent template.HTML
195 +
}
196 +
197 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
198 +
	web.Render(a.Templates, w, "index.html", indexPageData{BaseURL: a.BaseURL}, a.Log)
199 +
}
200 +
201 +
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
202 +
	snippets, err := getAllSnippets(a.DB)
203 +
	if err != nil {
204 +
		http.Error(w, "Server error", http.StatusInternalServerError)
205 +
		return
206 +
	}
207 +
	web.Render(a.Templates, w, "admin.html", adminPageData{BaseURL: a.BaseURL, Snippets: snippets}, a.Log)
208 +
}
209 +
210 +
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
211 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error"), Next: r.URL.Query().Get("next")}, a.Log)
212 +
}
213 +
214 +
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
215 +
	next := r.URL.Query().Get("next")
216 +
	if next == "" {
217 +
		next = "/admin"
218 +
	}
219 +
	if err := r.ParseForm(); err != nil {
220 +
		http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther)
221 +
		return
222 +
	}
223 +
	if a.APIKey == "" {
224 +
		http.Redirect(w, r, "/admin/login?error=No+API+key+configured", http.StatusSeeOther)
225 +
		return
226 +
	}
227 +
	if !auth.SecureEqual(r.FormValue("api_key"), a.APIKey) {
228 +
		http.Redirect(w, r, "/admin/login?error=Invalid+API+key&next="+url.QueryEscape(next), http.StatusSeeOther)
229 +
		return
230 +
	}
231 +
	token, err := a.Sessions.Create()
232 +
	if err != nil {
233 +
		http.Redirect(w, r, "/admin/login?error=Server+error", http.StatusSeeOther)
234 +
		return
235 +
	}
236 +
	a.Sessions.PruneExpired()
237 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
238 +
	target := "/admin"
239 +
	if strings.HasPrefix(next, "/") {
240 +
		target = next
241 +
	}
242 +
	http.Redirect(w, r, target, http.StatusSeeOther)
243 +
}
244 +
245 +
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
246 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
247 +
		a.Sessions.Delete(c.Value)
248 +
	}
249 +
	http.SetCookie(w, a.Sessions.ClearCookie())
250 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
251 +
}
252 +
253 +
func (a *App) adminDeleteSnippet(w http.ResponseWriter, r *http.Request) {
254 +
	_, _ = deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
255 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
256 +
}
257 +
258 +
func isCLIUserAgent(r *http.Request) bool {
259 +
	ua := strings.ToLower(r.Header.Get("User-Agent"))
260 +
	return strings.HasPrefix(ua, "curl/") || strings.HasPrefix(ua, "wget/") || strings.HasPrefix(ua, "httpie/")
261 +
}
262 +
263 +
func (a *App) viewSnippet(w http.ResponseWriter, r *http.Request) {
264 +
	snippet, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
265 +
	if err != nil {
266 +
		http.Error(w, "Internal server error", http.StatusInternalServerError)
267 +
		return
268 +
	}
269 +
	if snippet == nil {
270 +
		http.Error(w, "Snippet not found", http.StatusNotFound)
271 +
		return
272 +
	}
273 +
	if isCLIUserAgent(r) {
274 +
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
275 +
		_, _ = w.Write([]byte(snippet.Content))
276 +
		return
277 +
	}
278 +
	highlighted := highlight(snippet.Name, snippet.Content)
279 +
	web.Render(a.Templates, w, "snippet.html", snippetPageData{
280 +
		BaseURL:            a.BaseURL,
281 +
		Name:               snippet.Name,
282 +
		Content:            snippet.Content,
283 +
		HighlightedContent: template.HTML(highlighted),
284 +
	}, a.Log)
285 +
}
286 +
287 +
func (a *App) createSnippetForm(w http.ResponseWriter, r *http.Request) {
288 +
	if err := r.ParseForm(); err != nil {
289 +
		http.Error(w, "Bad request", http.StatusBadRequest)
290 +
		return
291 +
	}
292 +
	content := r.FormValue("content")
293 +
	if len(content) > a.MaxContentSize {
294 +
		http.Error(w, "Content too large", http.StatusRequestEntityTooLarge)
295 +
		return
296 +
	}
297 +
	sn, err := createSnippet(a.DB, r.FormValue("name"), content)
298 +
	if err != nil {
299 +
		http.Error(w, "Server error", http.StatusInternalServerError)
300 +
		return
301 +
	}
302 +
	http.Redirect(w, r, "/s/"+sn.ShortID, http.StatusSeeOther)
303 +
}
304 +
305 +
func (a *App) requireAPIKey(next http.HandlerFunc) http.HandlerFunc {
306 +
	return func(w http.ResponseWriter, r *http.Request) {
307 +
		if a.APIKey == "" {
308 +
			web.WriteJSON(w, http.StatusForbidden, map[string]any{"error": "No API key configured on server"})
309 +
			return
310 +
		}
311 +
		if auth.SecureEqual(r.Header.Get("x-api-key"), a.APIKey) {
312 +
			next(w, r)
313 +
			return
314 +
		}
315 +
		if a.Sessions.HasValid(r) {
316 +
			next(w, r)
317 +
			return
318 +
		}
319 +
		web.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "Invalid or missing API key"})
320 +
	}
321 +
}
322 +
323 +
func (a *App) apiList(w http.ResponseWriter, r *http.Request) {
324 +
	snippets, err := getAllSnippets(a.DB)
325 +
	if err != nil {
326 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
327 +
		return
328 +
	}
329 +
	web.WriteJSON(w, http.StatusOK, snippets)
330 +
}
331 +
332 +
func (a *App) apiGet(w http.ResponseWriter, r *http.Request) {
333 +
	s, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
334 +
	if err != nil {
335 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
336 +
		return
337 +
	}
338 +
	if s == nil {
339 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Snippet not found"})
340 +
		return
341 +
	}
342 +
	web.WriteJSON(w, http.StatusOK, s)
343 +
}
344 +
345 +
type apiCreateBody struct {
346 +
	Name    string `json:"name"`
347 +
	Content string `json:"content"`
348 +
}
349 +
350 +
func (a *App) apiCreate(w http.ResponseWriter, r *http.Request) {
351 +
	var body apiCreateBody
352 +
	if !web.DecodeJSON(w, r, &body) {
353 +
		return
354 +
	}
355 +
	if len(body.Content) > a.MaxContentSize {
356 +
		web.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Content too large. Maximum size is " + strconv.Itoa(a.MaxContentSize) + " bytes"})
357 +
		return
358 +
	}
359 +
	s, err := createSnippet(a.DB, body.Name, body.Content)
360 +
	if err != nil {
361 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
362 +
		return
363 +
	}
364 +
	web.WriteJSON(w, http.StatusCreated, s)
365 +
}
366 +
367 +
func (a *App) apiUpdate(w http.ResponseWriter, r *http.Request) {
368 +
	var body apiCreateBody
369 +
	if !web.DecodeJSON(w, r, &body) {
370 +
		return
371 +
	}
372 +
	if len(body.Content) > a.MaxContentSize {
373 +
		web.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Content too large"})
374 +
		return
375 +
	}
376 +
	s, err := updateSnippetByShortID(a.DB, r.PathValue("short_id"), body.Name, body.Content)
377 +
	if err != nil {
378 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
379 +
		return
380 +
	}
381 +
	if s == nil {
382 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Snippet not found"})
383 +
		return
384 +
	}
385 +
	web.WriteJSON(w, http.StatusOK, s)
386 +
}
387 +
388 +
func (a *App) apiDelete(w http.ResponseWriter, r *http.Request) {
389 +
	ok, err := deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
390 +
	if err != nil {
391 +
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
392 +
		return
393 +
	}
394 +
	if !ok {
395 +
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Snippet not found"})
396 +
		return
397 +
	}
398 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true})
399 +
}
400 +
401 +
func (a *App) requiresAuth(name string) bool {
402 +
	return a.AuthEndpoints["all"] || a.AuthEndpoints[name]
403 +
}
404 +
405 +
func (a *App) wrapIfAuth(name string, h http.HandlerFunc) http.HandlerFunc {
406 +
	if a.requiresAuth(name) {
407 +
		return a.requireAPIKey(h)
408 +
	}
409 +
	return h
410 +
}
411 +
412 +
func parseAuthEndpoints(raw string) map[string]bool {
413 +
	out := map[string]bool{}
414 +
	if strings.EqualFold(strings.TrimSpace(raw), "none") {
415 +
		return out
416 +
	}
417 +
	if raw == "" {
418 +
		out["api_delete"] = true
419 +
		out["api_list"] = true
420 +
		out["api_update"] = true
421 +
		return out
422 +
	}
423 +
	for _, p := range strings.Split(raw, ",") {
424 +
		if v := strings.ToLower(strings.TrimSpace(p)); v != "" {
425 +
			out[v] = true
426 +
		}
427 +
	}
428 +
	return out
429 +
}
430 +
431 +
// Run starts the sipp web server.
432 +
func Run(host string, port int) error {
433 +
	config.LoadDotEnv(".env")
434 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
435 +
436 +
	dbPath := config.Getenv("SIPP_DB_PATH", "sipp.sqlite")
437 +
	db, err := openDB(dbPath)
438 +
	if err != nil {
439 +
		return err
440 +
	}
441 +
442 +
	apiKey := os.Getenv("SIPP_API_KEY")
443 +
	authEndpoints := parseAuthEndpoints(os.Getenv("SIPP_AUTH_ENDPOINTS"))
444 +
	maxSize := config.GetenvInt("SIPP_MAX_CONTENT_SIZE", 512000)
445 +
	baseURL := strings.TrimRight(config.Getenv("BASE_URL", "http://localhost:3000"), "/")
446 +
	cookieSecure := config.GetenvBool("SIPP_COOKIE_SECURE", false)
447 +
448 +
	if len(authEndpoints) > 0 && apiKey == "" {
449 +
		logger.Warn("SIPP_AUTH_ENDPOINTS set but SIPP_API_KEY not configured")
450 +
	}
451 +
452 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: cookieSecure}
453 +
	if err := sessions.EnsureSchema(); err != nil {
454 +
		return err
455 +
	}
456 +
	sessions.PruneExpired()
457 +
458 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
459 +
460 +
	app := &App{
461 +
		DB: db, Log: logger, Templates: tmpl, Sessions: sessions,
462 +
		APIKey: apiKey, BaseURL: baseURL, CookieSecure: cookieSecure,
463 +
		AuthEndpoints: authEndpoints, MaxContentSize: maxSize,
464 +
	}
465 +
466 +
	mux := http.NewServeMux()
467 +
	mux.HandleFunc("GET /", app.indexHandler)
468 +
	mux.HandleFunc("GET /admin", app.Sessions.RequireSession("/admin/login", app.adminHandler))
469 +
	mux.HandleFunc("GET /admin/login", app.loginGet)
470 +
	mux.HandleFunc("POST /admin/login", app.loginPost)
471 +
	mux.HandleFunc("POST /admin/logout", app.logout)
472 +
	mux.HandleFunc("POST /admin/snippets/{short_id}/delete", app.Sessions.RequireSession("/admin/login", app.adminDeleteSnippet))
473 +
	mux.HandleFunc("GET /s/{short_id}", app.viewSnippet)
474 +
	mux.HandleFunc("POST /snippets", app.createSnippetForm)
475 +
476 +
	mux.HandleFunc("GET /api/snippets", app.wrapIfAuth("api_list", app.apiList))
477 +
	mux.HandleFunc("POST /api/snippets", app.wrapIfAuth("api_create", app.apiCreate))
478 +
	mux.HandleFunc("GET /api/snippets/{short_id}", app.wrapIfAuth("api_get", app.apiGet))
479 +
	mux.HandleFunc("PUT /api/snippets/{short_id}", app.wrapIfAuth("api_update", app.apiUpdate))
480 +
	mux.HandleFunc("DELETE /api/snippets/{short_id}", app.wrapIfAuth("api_delete", app.apiDelete))
481 +
482 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
483 +
	darkmatter.Mount(mux, "/assets")
484 +
485 +
	addr := host + ":" + strconv.Itoa(port)
486 +
	logger.Info("sipp-go server running", "addr", addr)
487 +
	return http.ListenAndServe(addr, mux)
488 +
}
489 +
490 +
// Silence unused import warnings; keep these for forward use.
491 +
var _ = json.Marshal
492 +
var _ = bytes.NewReader
493 +
var _ io.Reader = (*bytes.Reader)(nil)
494 +
var _ = log.Fatal
apps/sipp-go/server/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

apps/sipp-go/server/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/sipp-go/server/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/sipp-go/server/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/sipp-go/server/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/sipp-go/server/static/icon.png (added) +0 −0

Binary file — no preview.

apps/sipp-go/server/static/og.png (added) +0 −0

Binary file — no preview.

apps/sipp-go/server/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/assets/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/sipp-go/server/static/styles.css (added) +83 −0
1 +
/* sipp — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
/* Logo wraps an h1 in sipp markup. */
6 +
7 +
.logo h1 {
8 +
  font-size: 28px;
9 +
  font-weight: 700;
10 +
  text-transform: uppercase;
11 +
}
12 +
13 +
/* Snippet icon links */
14 +
15 +
.icon {
16 +
  display: flex;
17 +
  align-items: center;
18 +
  justify-content: center;
19 +
  color: #878787;
20 +
  width: 24px;
21 +
  height: 24px;
22 +
}
23 +
24 +
.icon svg {
25 +
  width: 24px;
26 +
  height: 24px;
27 +
}
28 +
29 +
.icon svg path {
30 +
  fill: #878787;
31 +
}
32 +
33 +
.icon:hover svg path {
34 +
  fill: #ffffff;
35 +
}
36 +
37 +
/* Create-snippet form */
38 +
39 +
#snippetForm {
40 +
  display: flex;
41 +
  flex-direction: column;
42 +
  gap: 1rem;
43 +
  width: 100%;
44 +
}
45 +
46 +
#snippetName {
47 +
  font-size: 14px;
48 +
  opacity: 0.7;
49 +
}
50 +
51 +
/* Highlighted snippet viewer */
52 +
53 +
.code-container {
54 +
  border: 1px solid #ffffff;
55 +
  height: 400px;
56 +
  overflow: auto;
57 +
  -webkit-overflow-scrolling: touch;
58 +
}
59 +
60 +
.code-container pre {
61 +
  background-color: #121113 !important;
62 +
  padding: 6px;
63 +
  margin: 0;
64 +
  min-height: 100%;
65 +
  font-size: 13px;
66 +
  line-height: 1.4;
67 +
  border: none;
68 +
}
69 +
70 +
/* Viewer action row */
71 +
72 +
.button-group {
73 +
  display: flex;
74 +
  gap: 0.5rem;
75 +
  flex-wrap: wrap;
76 +
}
77 +
78 +
/* Snippet page header variant (plain <a class="header">) */
79 +
80 +
.nav {
81 +
  width: 100%;
82 +
  margin-top: 2rem;
83 +
}
apps/sipp-go/server/templates/admin.html (added) +46 −0
1 +
{{define "admin.html"}}<!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>Sipp - Admin</title>
14 +
    <meta property="og:url" content="{{.BaseURL}}">
15 +
    <meta property="og:type" content="website">
16 +
    <meta property="og:title" content="Sipps">
17 +
    <meta property="og:image" content="{{.BaseURL}}/static/og.png">
18 +
  </head>
19 +
  <body>
20 +
    <div class="header">
21 +
      <a href="/" class="logo"><h1>SIPP</h1></a>
22 +
    </div>
23 +
    {{if not .Snippets}}
24 +
      <p class="empty">no snippets yet</p>
25 +
    {{else}}
26 +
      <div class="admin-list">
27 +
        {{range .Snippets}}
28 +
          <div class="admin-list-item">
29 +
            <div class="admin-list-info">
30 +
              <a href="/s/{{.ShortID}}" class="admin-list-title">{{.Name}}</a>
31 +
              <div class="admin-list-meta">
32 +
                <span class="admin-list-date">/s/{{.ShortID}}</span>
33 +
              </div>
34 +
            </div>
35 +
            <div class="admin-list-actions">
36 +
              <a href="/s/{{.ShortID}}">view</a>
37 +
              <form method="POST" action="/admin/snippets/{{.ShortID}}/delete" class="inline-form" onsubmit="return confirm('delete this snippet?')">
38 +
                <button type="submit" class="link-button danger">delete</button>
39 +
              </form>
40 +
            </div>
41 +
          </div>
42 +
        {{end}}
43 +
      </div>
44 +
    {{end}}
45 +
  </body>
46 +
</html>{{end}}
apps/sipp-go/server/templates/index.html (added) +51 −0
1 +
{{define "index.html"}}<!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>Sipp</title>
14 +
    <meta name="description" content="Minimal Code Sharing">
15 +
    <meta property="og:url" content="{{.BaseURL}}">
16 +
    <meta property="og:type" content="website">
17 +
    <meta property="og:title" content="Sipps">
18 +
    <meta property="og:description" content="Minimal Code Sharing">
19 +
    <meta property="og:image" content="{{.BaseURL}}/static/og.png">
20 +
    <meta name="twitter:card" content="summary_large_image">
21 +
    <meta property="twitter:url" content="{{.BaseURL}}">
22 +
    <meta name="twitter:title" content="Sipps">
23 +
    <meta name="twitter:description" content="Minimal Code Sharing">
24 +
    <meta name="twitter:image" content="{{.BaseURL}}/static/og.png">
25 +
  </head>
26 +
  <body>
27 +
    <div class="header">
28 +
      <a href="/" class="logo"><h1>SIPP</h1></a>
29 +
      <nav class="links">
30 +
        <a href="/admin">admin</a>
31 +
      </nav>
32 +
    </div>
33 +
    <form id="snippetForm" method="POST" action="/snippets">
34 +
      <div>
35 +
        <input placeholder="index.ts" type="text" id="name" name="name" required>
36 +
      </div>
37 +
      <div>
38 +
        <textarea placeholder="// paste your code here" id="content" name="content" required></textarea>
39 +
      </div>
40 +
      <button type="submit">Create Snippet</button>
41 +
    </form>
42 +
    <script>
43 +
      document.getElementById('content').addEventListener('keydown', (e) => {
44 +
        if (e.metaKey && e.key === 'Enter' || e.ctrlKey && e.key === 'Enter') {
45 +
          e.preventDefault();
46 +
          document.getElementById('snippetForm').requestSubmit();
47 +
        }
48 +
      });
49 +
    </script>
50 +
  </body>
51 +
</html>{{end}}
apps/sipp-go/server/templates/login.html (added) +24 −0
1 +
{{define "login.html"}}<!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 +
    <title>Sipp - Login</title>
10 +
  </head>
11 +
  <body>
12 +
    <header class="header">
13 +
      <a href="/" class="logo"><h1>SIPP</h1></a>
14 +
    </header>
15 +
    <main>
16 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
17 +
      <form method="POST" action="/admin/login{{if .Next}}?next={{.Next}}{{end}}" class="form">
18 +
        <label for="api_key">api key</label>
19 +
        <input type="password" id="api_key" name="api_key" autofocus required>
20 +
        <button type="submit">login</button>
21 +
      </form>
22 +
    </main>
23 +
  </body>
24 +
</html>{{end}}
apps/sipp-go/server/templates/snippet.html (added) +64 −0
1 +
{{define "snippet.html"}}<!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 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
7 +
    <link rel="stylesheet" href="/static/styles.css" />
8 +
    <meta name="theme-color" content="#121113" />
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>{{.Name}} | Sipp</title>
14 +
    <meta property="og:url" content="{{.BaseURL}}">
15 +
    <meta property="og:type" content="website">
16 +
    <meta property="og:title" content="Sipp | {{.Name}}">
17 +
    <meta property="og:image" content="{{.BaseURL}}/static/og.png">
18 +
  </head>
19 +
  <body>
20 +
    <div class="nav">
21 +
      <a href="/" class="header"><h1>SIPP</h1></a>
22 +
    </div>
23 +
    <div id="snippetForm">
24 +
      <label id="snippetName">{{.Name}}</label>
25 +
      <div class="code-container">{{.HighlightedContent}}</div>
26 +
      <textarea id="content" style="display:none;">{{.Content}}</textarea>
27 +
      <div class="button-group">
28 +
        <button type="button" id="copyLinkBtn" data-original-text="Copy Link">Copy Link</button>
29 +
        <button type="button" id="copyContentBtn" data-original-text="Copy Content">Copy Content</button>
30 +
        <button type="button" id="createNewBtn">Create New Snippet</button>
31 +
      </div>
32 +
    </div>
33 +
    <script>
34 +
      async function copyToClipboard(text, button) {
35 +
        try {
36 +
          await navigator.clipboard.writeText(text);
37 +
          showButtonFeedback(button, '✔ Copied', 'success');
38 +
        } catch (error) {
39 +
          showButtonFeedback(button, '✘ Failed', 'error');
40 +
        }
41 +
      }
42 +
      function showButtonFeedback(button, message, type) {
43 +
        const originalText = button.dataset.originalText || button.textContent;
44 +
        button.textContent = message;
45 +
        button.disabled = true;
46 +
        button.classList.add('copy-' + type);
47 +
        setTimeout(() => {
48 +
          button.textContent = originalText;
49 +
          button.disabled = false;
50 +
          button.classList.remove('copy-' + type);
51 +
        }, 1000);
52 +
      }
53 +
      document.getElementById('copyContentBtn').addEventListener('click', async () => {
54 +
        await copyToClipboard(document.getElementById('content').value, document.getElementById('copyContentBtn'));
55 +
      });
56 +
      document.getElementById('copyLinkBtn').addEventListener('click', async () => {
57 +
        await copyToClipboard(window.location.href, document.getElementById('copyLinkBtn'));
58 +
      });
59 +
      document.getElementById('createNewBtn').addEventListener('click', () => {
60 +
        window.location.href = '/';
61 +
      });
62 +
    </script>
63 +
  </body>
64 +
</html>{{end}}
crates-go/auth/auth.go (added) +207 −0
1 +
// Package auth provides session storage, password verification and small
2 +
// helpers shared by andromeda Go apps.
3 +
package auth
4 +
5 +
import (
6 +
	"crypto/rand"
7 +
	"crypto/subtle"
8 +
	"database/sql"
9 +
	"encoding/hex"
10 +
	"errors"
11 +
	"net/http"
12 +
	"strings"
13 +
	"time"
14 +
15 +
	"golang.org/x/crypto/bcrypt"
16 +
)
17 +
18 +
const shortIDAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
19 +
20 +
// Store manages session tokens in a sessions table inside the given DB.
21 +
type Store struct {
22 +
	DB           *sql.DB
23 +
	CookieName   string
24 +
	CookieSecure bool
25 +
	// MaxAge is the cookie lifetime. Defaults to 7 days when zero.
26 +
	MaxAge time.Duration
27 +
}
28 +
29 +
// EnsureSchema creates the sessions table if it does not exist.
30 +
func (s *Store) EnsureSchema() error {
31 +
	_, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS sessions (
32 +
		id         INTEGER PRIMARY KEY AUTOINCREMENT,
33 +
		token      TEXT NOT NULL UNIQUE,
34 +
		expires_at TEXT NOT NULL
35 +
	)`)
36 +
	return err
37 +
}
38 +
39 +
// Create issues a new session, persists it, and returns the raw token.
40 +
func (s *Store) Create() (string, error) {
41 +
	token, err := GenerateSessionToken()
42 +
	if err != nil {
43 +
		return "", err
44 +
	}
45 +
	expires := time.Now().UTC().Add(s.maxAge())
46 +
	if _, err := s.DB.Exec(`INSERT INTO sessions (token, expires_at) VALUES (?, ?)`,
47 +
		token, expires.Format("2006-01-02 15:04:05")); err != nil {
48 +
		return "", err
49 +
	}
50 +
	return token, nil
51 +
}
52 +
53 +
// Valid reports whether the given token exists and has not expired.
54 +
func (s *Store) Valid(token string) bool {
55 +
	if token == "" {
56 +
		return false
57 +
	}
58 +
	var expires string
59 +
	err := s.DB.QueryRow(`SELECT expires_at FROM sessions WHERE token = ?`, token).Scan(&expires)
60 +
	if err != nil {
61 +
		return false
62 +
	}
63 +
	t, err := time.ParseInLocation("2006-01-02 15:04:05", expires, time.UTC)
64 +
	return err == nil && t.After(time.Now().UTC())
65 +
}
66 +
67 +
// Delete removes the given session token if present.
68 +
func (s *Store) Delete(token string) {
69 +
	_, _ = s.DB.Exec(`DELETE FROM sessions WHERE token = ?`, token)
70 +
}
71 +
72 +
// PruneExpired removes all expired session rows.
73 +
func (s *Store) PruneExpired() {
74 +
	_, _ = s.DB.Exec(`DELETE FROM sessions WHERE expires_at < datetime('now')`)
75 +
}
76 +
77 +
// HasValid checks the cookie on r and returns true if it carries a live session.
78 +
func (s *Store) HasValid(r *http.Request) bool {
79 +
	c, err := r.Cookie(s.CookieName)
80 +
	if err != nil || c.Value == "" {
81 +
		return false
82 +
	}
83 +
	return s.Valid(c.Value)
84 +
}
85 +
86 +
// RequireSession wraps next and redirects to redirectPath when no valid session
87 +
// cookie is present.
88 +
func (s *Store) RequireSession(redirectPath string, next http.HandlerFunc) http.HandlerFunc {
89 +
	return func(w http.ResponseWriter, r *http.Request) {
90 +
		if !s.HasValid(r) {
91 +
			http.Redirect(w, r, redirectPath, http.StatusSeeOther)
92 +
			return
93 +
		}
94 +
		next(w, r)
95 +
	}
96 +
}
97 +
98 +
// SessionCookie returns a cookie configured with the store's name, security
99 +
// flag and MaxAge.
100 +
func (s *Store) SessionCookie(token string) *http.Cookie {
101 +
	return &http.Cookie{
102 +
		Name:     s.CookieName,
103 +
		Value:    token,
104 +
		Path:     "/",
105 +
		HttpOnly: true,
106 +
		Secure:   s.CookieSecure,
107 +
		SameSite: http.SameSiteLaxMode,
108 +
		MaxAge:   int(s.maxAge().Seconds()),
109 +
	}
110 +
}
111 +
112 +
// ClearCookie returns an expired cookie used to log out a session.
113 +
func (s *Store) ClearCookie() *http.Cookie {
114 +
	return &http.Cookie{
115 +
		Name:     s.CookieName,
116 +
		Value:    "",
117 +
		Path:     "/",
118 +
		HttpOnly: true,
119 +
		Secure:   s.CookieSecure,
120 +
		SameSite: http.SameSiteLaxMode,
121 +
		MaxAge:   -1,
122 +
	}
123 +
}
124 +
125 +
func (s *Store) maxAge() time.Duration {
126 +
	if s.MaxAge > 0 {
127 +
		return s.MaxAge
128 +
	}
129 +
	return 7 * 24 * time.Hour
130 +
}
131 +
132 +
// RequireAPIKey wraps next with a strict X-API-Key header check.
133 +
// Returns 403 if expected is empty, 401 on mismatch.
134 +
func RequireAPIKey(expected string, next http.HandlerFunc) http.HandlerFunc {
135 +
	return func(w http.ResponseWriter, r *http.Request) {
136 +
		if expected == "" {
137 +
			http.Error(w, "API key not configured on server", http.StatusForbidden)
138 +
			return
139 +
		}
140 +
		if !SecureEqual(r.Header.Get("x-api-key"), expected) {
141 +
			http.Error(w, "Invalid API key", http.StatusUnauthorized)
142 +
			return
143 +
		}
144 +
		next(w, r)
145 +
	}
146 +
}
147 +
148 +
// RequireBearerOrSession allows requests carrying either a matching bearer
149 +
// token or a valid session cookie. Falls through with 401 JSON otherwise.
150 +
func RequireBearerOrSession(store *Store, expectedBearer string, next http.HandlerFunc) http.HandlerFunc {
151 +
	return func(w http.ResponseWriter, r *http.Request) {
152 +
		if expectedBearer != "" {
153 +
			authz := r.Header.Get("Authorization")
154 +
			if strings.HasPrefix(strings.ToLower(authz), "bearer ") &&
155 +
				SecureEqual(strings.TrimSpace(authz[7:]), expectedBearer) {
156 +
				next(w, r)
157 +
				return
158 +
			}
159 +
		}
160 +
		if store != nil && store.HasValid(r) {
161 +
			next(w, r)
162 +
			return
163 +
		}
164 +
		w.Header().Set("Content-Type", "application/json")
165 +
		w.WriteHeader(http.StatusUnauthorized)
166 +
		_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
167 +
	}
168 +
}
169 +
170 +
// VerifyPassword checks input against expected. If expected looks like a bcrypt
171 +
// hash (`$2`-prefixed) it is compared as such, otherwise as plain text.
172 +
func VerifyPassword(input, expected string) bool {
173 +
	if strings.HasPrefix(expected, "$2") {
174 +
		return bcrypt.CompareHashAndPassword([]byte(expected), []byte(input)) == nil
175 +
	}
176 +
	return SecureEqual(input, expected)
177 +
}
178 +
179 +
// SecureEqual reports whether a and b are equal in constant time.
180 +
func SecureEqual(a, b string) bool {
181 +
	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
182 +
}
183 +
184 +
// GenerateSessionToken returns a 32-byte random hex token.
185 +
func GenerateSessionToken() (string, error) {
186 +
	buf := make([]byte, 32)
187 +
	if _, err := rand.Read(buf); err != nil {
188 +
		return "", err
189 +
	}
190 +
	return hex.EncodeToString(buf), nil
191 +
}
192 +
193 +
// GenerateShortID returns an n-character URL-safe identifier.
194 +
func GenerateShortID(n int) (string, error) {
195 +
	if n <= 0 {
196 +
		return "", errors.New("auth: short id length must be positive")
197 +
	}
198 +
	buf := make([]byte, n)
199 +
	if _, err := rand.Read(buf); err != nil {
200 +
		return "", err
201 +
	}
202 +
	out := make([]byte, n)
203 +
	for i, b := range buf {
204 +
		out[i] = shortIDAlphabet[int(b)%len(shortIDAlphabet)]
205 +
	}
206 +
	return string(out), nil
207 +
}
crates-go/auth/go.mod (added) +5 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/auth
2 +
3 +
go 1.24
4 +
5 +
require golang.org/x/crypto v0.39.0
crates-go/auth/go.sum (added) +2 −0
1 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
2 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
crates-go/config/config.go (added) +58 −0
1 +
// Package config holds tiny env helpers shared by andromeda Go apps.
2 +
package config
3 +
4 +
import (
5 +
	"os"
6 +
	"strconv"
7 +
	"strings"
8 +
)
9 +
10 +
// Getenv returns the trimmed value of key or fallback when unset/blank.
11 +
func Getenv(key, fallback string) string {
12 +
	if v := strings.TrimSpace(os.Getenv(key)); v != "" {
13 +
		return v
14 +
	}
15 +
	return fallback
16 +
}
17 +
18 +
// GetenvInt parses key as int or returns fallback.
19 +
func GetenvInt(key string, fallback int) int {
20 +
	if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(key))); err == nil {
21 +
		return v
22 +
	}
23 +
	return fallback
24 +
}
25 +
26 +
// GetenvBool returns true for "true"/"1"/"yes" (case-insensitive), false for the
27 +
// matching negatives, and fallback otherwise.
28 +
func GetenvBool(key string, fallback bool) bool {
29 +
	v := strings.TrimSpace(os.Getenv(key))
30 +
	switch strings.ToLower(v) {
31 +
	case "true", "1", "yes", "on":
32 +
		return true
33 +
	case "false", "0", "no", "off":
34 +
		return false
35 +
	}
36 +
	return fallback
37 +
}
38 +
39 +
// LoadDotEnv reads a KEY=VALUE file and populates os.Environ for keys not
40 +
// already set. Missing files are silently ignored.
41 +
func LoadDotEnv(path string) {
42 +
	data, err := os.ReadFile(path)
43 +
	if err != nil {
44 +
		return
45 +
	}
46 +
	for _, line := range strings.Split(string(data), "\n") {
47 +
		line = strings.TrimSpace(line)
48 +
		if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
49 +
			continue
50 +
		}
51 +
		parts := strings.SplitN(line, "=", 2)
52 +
		key := strings.TrimSpace(parts[0])
53 +
		val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
54 +
		if os.Getenv(key) == "" {
55 +
			_ = os.Setenv(key, val)
56 +
		}
57 +
	}
58 +
}
crates-go/config/go.mod (added) +3 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/config
2 +
3 +
go 1.24
crates-go/darkmatter/assets/index.html (added) +249 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <meta name="theme-color" content="#121113">
7 +
  <title>Darkmatter — Gallery</title>
8 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
9 +
  <style>
10 +
    .section { width: 100%; border-bottom: 1px solid #333; padding-bottom: 2rem; }
11 +
    .section h2 { font-size: 16px; margin-bottom: 0.5rem; }
12 +
    .section p.desc { font-size: 12px; opacity: 0.5; margin-bottom: 0.75rem; }
13 +
    .row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
14 +
    .swatch { width: 40px; height: 24px; border: 1px solid #333; }
15 +
    .opacity-scale { display: flex; flex-direction: column; gap: 0.25rem; }
16 +
  </style>
17 +
</head>
18 +
<body>
19 +
  <header class="header">
20 +
    <a href="/darkmatter" class="logo">DARKMATTER</a>
21 +
    <nav class="links">
22 +
      <a href="#reset">reset</a>
23 +
      <a href="#typography">typography</a>
24 +
      <a href="#buttons">buttons</a>
25 +
      <a href="#forms">forms</a>
26 +
      <a href="#feedback">feedback</a>
27 +
      <a href="#lists">lists</a>
28 +
      <a href="#tags">tags</a>
29 +
      <a href="#table">table</a>
30 +
      <a href="#code">code</a>
31 +
    </nav>
32 +
  </header>
33 +
34 +
  <main>
35 +
    <section class="section" id="reset">
36 +
      <h2>Palette</h2>
37 +
      <p class="desc">Background <code>#121113</code>, foreground <code>#ffffff</code>, grays <code>#1e1c1f</code> / <code>#333</code> / <code>#555</code>. No accent colors.</p>
38 +
      <div class="row">
39 +
        <div class="swatch" style="background:#121113"></div>
40 +
        <div class="swatch" style="background:#1e1c1f"></div>
41 +
        <div class="swatch" style="background:#333"></div>
42 +
        <div class="swatch" style="background:#555"></div>
43 +
        <div class="swatch" style="background:#ffffff"></div>
44 +
      </div>
45 +
    </section>
46 +
47 +
    <section class="section" id="typography">
48 +
      <h2>Typography</h2>
49 +
      <p class="desc">Commit Mono. Hierarchy by opacity, not gray hex.</p>
50 +
      <div class="opacity-scale">
51 +
        <div>Primary text — opacity 1.0</div>
52 +
        <div style="opacity: 0.7">Secondary text — opacity 0.7 (labels, blockquotes)</div>
53 +
        <div style="opacity: 0.5">Tertiary text — opacity 0.5 (metadata, dates, table headers)</div>
54 +
        <div style="opacity: 0.3">Muted text — opacity 0.3 (null, placeholder)</div>
55 +
      </div>
56 +
      <div class="row" style="margin-top: 0.75rem">
57 +
        <span style="font-size: 28px; font-weight: 700; text-transform: uppercase">LOGO 28</span>
58 +
        <span style="font-size: 18px">h1 18</span>
59 +
        <span style="font-size: 16px">h2 / title 16</span>
60 +
        <span style="font-size: 14px">body 14</span>
61 +
        <span style="font-size: 12px">meta 12</span>
62 +
        <span style="font-size: 11px">tag 11</span>
63 +
      </div>
64 +
    </section>
65 +
66 +
    <section class="section" id="buttons">
67 +
      <h2>Buttons &amp; links</h2>
68 +
      <p class="desc"><code>button</code>, <code>.btn</code>, <code>.link-button</code>, <code>.link-button.danger</code>, <code>.spinner</code>.</p>
69 +
      <div class="row">
70 +
        <button>Submit</button>
71 +
        <a href="#" class="btn">Link as button</a>
72 +
        <button class="loading">Saving<span class="spinner"></span></button>
73 +
        <button class="link-button">edit</button>
74 +
        <button class="link-button danger">delete</button>
75 +
        <a href="#">plain link</a>
76 +
      </div>
77 +
    </section>
78 +
79 +
    <section class="section" id="forms">
80 +
      <h2>Form controls</h2>
81 +
      <p class="desc">Inputs use 16px font-size to prevent iOS focus zoom. All <code>-webkit-appearance: none</code> to kill default iOS chrome.</p>
82 +
      <form class="form" onsubmit="event.preventDefault()">
83 +
        <div class="form-field">
84 +
          <label for="demo-text">Text input</label>
85 +
          <input id="demo-text" type="text" placeholder="placeholder">
86 +
        </div>
87 +
        <div class="form-field">
88 +
          <label for="demo-search">Search</label>
89 +
          <input id="demo-search" type="search" placeholder="search...">
90 +
        </div>
91 +
        <div class="form-row">
92 +
          <div class="form-field">
93 +
            <label for="demo-a">Split field A</label>
94 +
            <input id="demo-a" type="text">
95 +
          </div>
96 +
          <div class="form-field">
97 +
            <label for="demo-b">Split field B</label>
98 +
            <input id="demo-b" type="text">
99 +
          </div>
100 +
        </div>
101 +
        <div class="form-field">
102 +
          <label for="demo-select">Select</label>
103 +
          <select id="demo-select">
104 +
            <option>one</option>
105 +
            <option>two</option>
106 +
          </select>
107 +
        </div>
108 +
        <div class="form-field">
109 +
          <label for="demo-textarea">Textarea</label>
110 +
          <textarea id="demo-textarea" style="min-height: 120px">multiline input</textarea>
111 +
        </div>
112 +
        <div class="form-field">
113 +
          <label for="demo-file">File upload</label>
114 +
          <input id="demo-file" type="file">
115 +
        </div>
116 +
        <div class="form-field checkbox-field">
117 +
          <label>
118 +
            <input type="checkbox" checked>
119 +
            Checkbox (checked)
120 +
          </label>
121 +
          <label>
122 +
            <input type="checkbox">
123 +
            Checkbox (unchecked)
124 +
          </label>
125 +
        </div>
126 +
        <div class="form-field">
127 +
          <label>
128 +
            <input type="radio" name="demo-radio" checked> Option A
129 +
          </label>
130 +
          <label>
131 +
            <input type="radio" name="demo-radio"> Option B
132 +
          </label>
133 +
        </div>
134 +
        <div class="switch-row">
135 +
          <label class="switch">
136 +
            <input type="checkbox" checked>
137 +
            <span class="switch-slider"></span>
138 +
          </label>
139 +
          <span class="switch-label">Switch toggle</span>
140 +
        </div>
141 +
        <div class="form-actions">
142 +
          <button type="submit">Save</button>
143 +
          <button type="button" class="link-button danger">Cancel</button>
144 +
        </div>
145 +
      </form>
146 +
    </section>
147 +
148 +
    <section class="section" id="feedback">
149 +
      <h2>Feedback</h2>
150 +
      <p class="desc"><code>.error</code>, <code>.success</code>, <code>.empty</code>. No red/green — borders + opacity only.</p>
151 +
      <div class="error">Something went wrong.</div>
152 +
      <div class="success" style="margin-top: 0.5rem">Saved successfully.</div>
153 +
      <div class="empty" style="margin-top: 0.5rem">No items yet.</div>
154 +
    </section>
155 +
156 +
    <section class="section" id="lists">
157 +
      <h2>Item list</h2>
158 +
      <p class="desc"><code>.item-list</code> / <code>.item</code> — stacked with <code>#333</code> bottom divider.</p>
159 +
      <div class="item-list">
160 +
        <a href="#" class="item">
161 +
          <div class="item-title">First entry title</div>
162 +
          <div class="item-meta">apr 18, 2026</div>
163 +
        </a>
164 +
        <a href="#" class="item">
165 +
          <div class="item-title">Second entry title</div>
166 +
          <div class="item-meta">apr 17, 2026</div>
167 +
        </a>
168 +
      </div>
169 +
170 +
      <h2 style="margin-top: 1rem">Admin list</h2>
171 +
      <p class="desc"><code>.admin-list</code> — horizontal row with inline actions.</p>
172 +
      <div class="admin-list">
173 +
        <div class="admin-list-item">
174 +
          <div class="admin-list-info">
175 +
            <div class="admin-list-title">example-item-one</div>
176 +
            <div class="admin-list-meta">
177 +
              <span class="status-badge status-published">published</span>
178 +
              <span class="admin-list-date">2026-04-18</span>
179 +
            </div>
180 +
          </div>
181 +
          <div class="admin-list-actions">
182 +
            <a href="#">edit</a>
183 +
            <button class="link-button danger">delete</button>
184 +
          </div>
185 +
        </div>
186 +
        <div class="admin-list-item">
187 +
          <div class="admin-list-info">
188 +
            <div class="admin-list-title">example-item-two</div>
189 +
            <div class="admin-list-meta">
190 +
              <span class="status-badge status-draft">draft</span>
191 +
              <span class="admin-list-date">2026-04-17</span>
192 +
            </div>
193 +
          </div>
194 +
          <div class="admin-list-actions">
195 +
            <a href="#">edit</a>
196 +
            <button class="link-button danger">delete</button>
197 +
          </div>
198 +
        </div>
199 +
      </div>
200 +
    </section>
201 +
202 +
    <section class="section" id="tags">
203 +
      <h2>Tags</h2>
204 +
      <div class="row">
205 +
        <span class="tag">rust</span>
206 +
        <span class="tag">web</span>
207 +
        <span class="tag">css</span>
208 +
      </div>
209 +
    </section>
210 +
211 +
    <section class="section" id="table">
212 +
      <h2>Table</h2>
213 +
      <table>
214 +
        <thead>
215 +
          <tr>
216 +
            <th>name</th>
217 +
            <th>status</th>
218 +
            <th>date</th>
219 +
          </tr>
220 +
        </thead>
221 +
        <tbody>
222 +
          <tr>
223 +
            <td>first</td>
224 +
            <td>ok</td>
225 +
            <td style="opacity: 0.5">2026-04-18</td>
226 +
          </tr>
227 +
          <tr>
228 +
            <td>second</td>
229 +
            <td>ok</td>
230 +
            <td style="opacity: 0.5">2026-04-17</td>
231 +
          </tr>
232 +
        </tbody>
233 +
      </table>
234 +
    </section>
235 +
236 +
    <section class="section" id="code">
237 +
      <h2>Code</h2>
238 +
      <p>Inline <code>let x = 42;</code> and block:</p>
239 +
      <pre><code>fn main() {
240 +
    println!("hello, darkmatter");
241 +
}</code></pre>
242 +
    </section>
243 +
  </main>
244 +
245 +
  <footer class="footer">
246 +
    <span style="opacity: 0.5; font-size: 12px">darkmatter-css · andromeda workspace</span>
247 +
  </footer>
248 +
</body>
249 +
</html>
crates-go/darkmatter/darkmatter.go (added) +54 −0
1 +
// Package darkmatter ships the shared CSS + fonts used by andromeda Go apps.
2 +
package darkmatter
3 +
4 +
import (
5 +
	"embed"
6 +
	"mime"
7 +
	"net/http"
8 +
	"path"
9 +
	"strings"
10 +
)
11 +
12 +
//go:embed assets/darkmatter.css assets/index.html assets/fonts/*
13 +
var FS embed.FS
14 +
15 +
// Mount registers the darkmatter routes (css, fonts, gallery) on mux.
16 +
//   - <assetPrefix>/darkmatter.css
17 +
//   - <assetPrefix>/fonts/{file}
18 +
//   - /darkmatter and /darkmatter/ (gallery)
19 +
//
20 +
// assetPrefix is normally "/assets". The leading slash is required.
21 +
func Mount(mux *http.ServeMux, assetPrefix string) {
22 +
	assetPrefix = strings.TrimRight(assetPrefix, "/")
23 +
	if assetPrefix == "" {
24 +
		assetPrefix = "/assets"
25 +
	}
26 +
27 +
	mux.HandleFunc("GET "+assetPrefix+"/darkmatter.css", func(w http.ResponseWriter, r *http.Request) {
28 +
		serve(w, "assets/darkmatter.css", "text/css; charset=utf-8")
29 +
	})
30 +
	mux.HandleFunc("GET "+assetPrefix+"/fonts/{file}", func(w http.ResponseWriter, r *http.Request) {
31 +
		file := r.PathValue("file")
32 +
		ct := mime.TypeByExtension(path.Ext(file))
33 +
		if ct == "" {
34 +
			ct = "application/octet-stream"
35 +
		}
36 +
		serve(w, "assets/fonts/"+file, ct)
37 +
	})
38 +
	mux.HandleFunc("GET /darkmatter", func(w http.ResponseWriter, r *http.Request) {
39 +
		serve(w, "assets/index.html", "text/html; charset=utf-8")
40 +
	})
41 +
	mux.HandleFunc("GET /darkmatter/", func(w http.ResponseWriter, r *http.Request) {
42 +
		serve(w, "assets/index.html", "text/html; charset=utf-8")
43 +
	})
44 +
}
45 +
46 +
func serve(w http.ResponseWriter, name, contentType string) {
47 +
	data, err := FS.ReadFile(name)
48 +
	if err != nil {
49 +
		http.Error(w, "not found", http.StatusNotFound)
50 +
		return
51 +
	}
52 +
	w.Header().Set("Content-Type", contentType)
53 +
	_, _ = w.Write(data)
54 +
}
crates-go/darkmatter/go.mod (added) +3 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/darkmatter
2 +
3 +
go 1.24
crates-go/web/go.mod (added) +3 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/web
2 +
3 +
go 1.24
crates-go/web/web.go (added) +72 −0
1 +
// Package web provides shared HTTP helpers used across andromeda Go apps.
2 +
package web
3 +
4 +
import (
5 +
	"embed"
6 +
	"encoding/json"
7 +
	"html/template"
8 +
	"log/slog"
9 +
	"mime"
10 +
	"net/http"
11 +
	"net/url"
12 +
	"path/filepath"
13 +
	"strings"
14 +
)
15 +
16 +
// EmbeddedHandler serves files from an embed.FS under the given URL prefix.
17 +
// Files are looked up relative to the same prefix inside the embedded FS.
18 +
func EmbeddedHandler(fs embed.FS, prefix string) http.HandlerFunc {
19 +
	return func(w http.ResponseWriter, r *http.Request) {
20 +
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
21 +
		path := filepath.ToSlash(filepath.Join(prefix, name))
22 +
		data, err := fs.ReadFile(path)
23 +
		if err != nil {
24 +
			http.NotFound(w, r)
25 +
			return
26 +
		}
27 +
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
28 +
			w.Header().Set("Content-Type", ct)
29 +
		}
30 +
		_, _ = w.Write(data)
31 +
	}
32 +
}
33 +
34 +
// Render executes a named template into w with status 200. Errors are logged
35 +
// and surfaced as HTTP 500.
36 +
func Render(t *template.Template, w http.ResponseWriter, name string, data any, log *slog.Logger) {
37 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
38 +
	if err := t.ExecuteTemplate(w, name, data); err != nil {
39 +
		if log != nil {
40 +
			log.Error("template render failed", "name", name, "err", err)
41 +
		}
42 +
		http.Error(w, "template error", http.StatusInternalServerError)
43 +
	}
44 +
}
45 +
46 +
// WriteJSON writes data as JSON with the given status code.
47 +
func WriteJSON(w http.ResponseWriter, status int, data any) {
48 +
	w.Header().Set("Content-Type", "application/json")
49 +
	w.WriteHeader(status)
50 +
	_ = json.NewEncoder(w).Encode(data)
51 +
}
52 +
53 +
// DecodeJSON decodes r.Body into dst. On failure it writes a 400 JSON error
54 +
// and returns false.
55 +
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
56 +
	defer r.Body.Close()
57 +
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
58 +
		WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON"})
59 +
		return false
60 +
	}
61 +
	return true
62 +
}
63 +
64 +
// RedirectWithError issues a 303 redirect to target with ?error=msg appended.
65 +
func RedirectWithError(w http.ResponseWriter, r *http.Request, target, msg string) {
66 +
	http.Redirect(w, r, target+"?error="+url.QueryEscape(msg), http.StatusSeeOther)
67 +
}
68 +
69 +
// RedirectWithSuccess issues a 303 redirect to target with ?success=msg appended.
70 +
func RedirectWithSuccess(w http.ResponseWriter, r *http.Request, target, msg string) {
71 +
	http.Redirect(w, r, target+"?success="+url.QueryEscape(msg), http.StatusSeeOther)
72 +
}