Merge pull request #45 from stevedylandev/chore/go-rewrite b6bc210a
Steve Simkins · 2026-05-16 19:24 335 file(s) · +20251 −8
.gitignore +24 −0
1 1
/target
2 2
*.sqlite
3 +
*.sqlite-journal
4 +
*.sqlite-wal
5 +
*.sqlite-shm
3 6
*.db
4 7
.env
5 8
.DS_Store
6 9
apps/posts/uploads
7 10
docs/node_modules
11 +
12 +
# Go
13 +
*.exe
14 +
*.dll
15 +
*.so
16 +
*.dylib
17 +
*.test
18 +
*.out
19 +
coverage.*
20 +
vendor/
21 +
apps/bookmarks-go/bookmarks-go
22 +
apps/cellar-go/cellar-go
23 +
apps/easel-go/easel-go
24 +
apps/feeds-go/feeds-go
25 +
apps/jotts-go/jotts-go
26 +
apps/library-go/library-go
27 +
apps/og-go/og-go
28 +
apps/posts-go/posts-go
29 +
apps/shrink-go/shrink-go
30 +
apps/sipp-go/sipp-cli
31 +
apps/sipp-go/sipp-server
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) +220 −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 +
)
11 +
12 +
const schema = `
13 +
CREATE TABLE IF NOT EXISTS categories (
14 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
15 +
    short_id   TEXT NOT NULL UNIQUE,
16 +
    name       TEXT NOT NULL UNIQUE,
17 +
    position   INTEGER NOT NULL DEFAULT 0,
18 +
    created_at INTEGER NOT NULL
19 +
);
20 +
21 +
CREATE TABLE IF NOT EXISTS links (
22 +
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
23 +
    short_id     TEXT NOT NULL UNIQUE,
24 +
    title        TEXT NOT NULL,
25 +
    url          TEXT NOT NULL,
26 +
    favicon_url  TEXT,
27 +
    category_id  INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
28 +
    created_at   INTEGER NOT NULL
29 +
);
30 +
31 +
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category_id, created_at DESC);
32 +
`
33 +
34 +
func listCategories(db *sql.DB) ([]Category, error) {
35 +
	rows, err := db.Query(`SELECT id, short_id, name, position FROM categories ORDER BY position ASC, name COLLATE NOCASE`)
36 +
	if err != nil {
37 +
		return nil, err
38 +
	}
39 +
	defer rows.Close()
40 +
	out := []Category{}
41 +
	for rows.Next() {
42 +
		var c Category
43 +
		if err := rows.Scan(&c.ID, &c.ShortID, &c.Name, &c.Position); err != nil {
44 +
			return nil, err
45 +
		}
46 +
		out = append(out, c)
47 +
	}
48 +
	return out, rows.Err()
49 +
}
50 +
51 +
func createCategory(db *sql.DB, name string) (*Category, error) {
52 +
	shortID, err := auth.GenerateShortID(10)
53 +
	if err != nil {
54 +
		return nil, err
55 +
	}
56 +
	now := time.Now().UTC().Unix()
57 +
	var nextPos int64
58 +
	if err := db.QueryRow(`SELECT COALESCE(MAX(position), 0) + 1 FROM categories`).Scan(&nextPos); err != nil {
59 +
		return nil, err
60 +
	}
61 +
	res, err := db.Exec(`INSERT INTO categories (short_id, name, position, created_at) VALUES (?, ?, ?, ?)`, shortID, name, nextPos, now)
62 +
	if err != nil {
63 +
		return nil, err
64 +
	}
65 +
	id, _ := res.LastInsertId()
66 +
	return &Category{ID: id, ShortID: shortID, Name: name, Position: nextPos}, nil
67 +
}
68 +
69 +
func deleteCategoryByShortID(db *sql.DB, shortID string) (bool, error) {
70 +
	res, err := db.Exec(`DELETE FROM categories WHERE short_id = ?`, shortID)
71 +
	if err != nil {
72 +
		return false, err
73 +
	}
74 +
	n, _ := res.RowsAffected()
75 +
	return n > 0, nil
76 +
}
77 +
78 +
func moveCategory(db *sql.DB, shortID string, direction int) (bool, error) {
79 +
	tx, err := db.Begin()
80 +
	if err != nil {
81 +
		return false, err
82 +
	}
83 +
	defer tx.Rollback()
84 +
85 +
	var curID, curPos int64
86 +
	err = tx.QueryRow(`SELECT id, position FROM categories WHERE short_id = ?`, shortID).Scan(&curID, &curPos)
87 +
	if errors.Is(err, sql.ErrNoRows) {
88 +
		return false, nil
89 +
	}
90 +
	if err != nil {
91 +
		return false, err
92 +
	}
93 +
94 +
	var nbID, nbPos int64
95 +
	if direction < 0 {
96 +
		err = tx.QueryRow(`SELECT id, position FROM categories WHERE position < ? ORDER BY position DESC LIMIT 1`, curPos).Scan(&nbID, &nbPos)
97 +
	} else {
98 +
		err = tx.QueryRow(`SELECT id, position FROM categories WHERE position > ? ORDER BY position ASC LIMIT 1`, curPos).Scan(&nbID, &nbPos)
99 +
	}
100 +
	if errors.Is(err, sql.ErrNoRows) {
101 +
		return false, nil
102 +
	}
103 +
	if err != nil {
104 +
		return false, err
105 +
	}
106 +
107 +
	if _, err := tx.Exec(`UPDATE categories SET position = ? WHERE id = ?`, nbPos, curID); err != nil {
108 +
		return false, err
109 +
	}
110 +
	if _, err := tx.Exec(`UPDATE categories SET position = ? WHERE id = ?`, curPos, nbID); err != nil {
111 +
		return false, err
112 +
	}
113 +
	if err := tx.Commit(); err != nil {
114 +
		return false, err
115 +
	}
116 +
	return true, nil
117 +
}
118 +
119 +
func getCategoryByName(db *sql.DB, name string) (*Category, error) {
120 +
	name = strings.TrimSpace(name)
121 +
	var c Category
122 +
	err := db.QueryRow(`SELECT id, short_id, name, position FROM categories WHERE name = ?`, name).Scan(&c.ID, &c.ShortID, &c.Name, &c.Position)
123 +
	if errors.Is(err, sql.ErrNoRows) {
124 +
		return nil, nil
125 +
	}
126 +
	if err != nil {
127 +
		return nil, err
128 +
	}
129 +
	return &c, nil
130 +
}
131 +
132 +
func listLinks(db *sql.DB) ([]Link, error) {
133 +
	rows, err := db.Query(`SELECT id, short_id, title, url, favicon_url, category_id, created_at FROM links ORDER BY created_at DESC`)
134 +
	if err != nil {
135 +
		return nil, err
136 +
	}
137 +
	defer rows.Close()
138 +
	out := []Link{}
139 +
	for rows.Next() {
140 +
		var l Link
141 +
		var fav sql.NullString
142 +
		if err := rows.Scan(&l.ID, &l.ShortID, &l.Title, &l.URL, &fav, &l.CategoryID, &l.CreatedAt); err != nil {
143 +
			return nil, err
144 +
		}
145 +
		if fav.Valid && fav.String != "" {
146 +
			s := fav.String
147 +
			l.FaviconURL = &s
148 +
		}
149 +
		out = append(out, l)
150 +
	}
151 +
	return out, rows.Err()
152 +
}
153 +
154 +
func createLink(db *sql.DB, title, url string, faviconURL *string, categoryID int64) (*Link, error) {
155 +
	shortID, err := auth.GenerateShortID(10)
156 +
	if err != nil {
157 +
		return nil, err
158 +
	}
159 +
	now := time.Now().UTC().Unix()
160 +
	var fav any
161 +
	if faviconURL != nil && *faviconURL != "" {
162 +
		fav = *faviconURL
163 +
	}
164 +
	res, err := db.Exec(`INSERT INTO links (short_id, title, url, favicon_url, category_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
165 +
		shortID, title, url, fav, categoryID, now)
166 +
	if err != nil {
167 +
		return nil, err
168 +
	}
169 +
	id, _ := res.LastInsertId()
170 +
	link := &Link{ID: id, ShortID: shortID, Title: title, URL: url, CategoryID: categoryID, CreatedAt: now}
171 +
	if faviconURL != nil && *faviconURL != "" {
172 +
		s := *faviconURL
173 +
		link.FaviconURL = &s
174 +
	}
175 +
	return link, nil
176 +
}
177 +
178 +
func listLinksMissingFavicon(db *sql.DB) ([]struct {
179 +
	ID  int64
180 +
	URL string
181 +
}, error) {
182 +
	rows, err := db.Query(`SELECT id, url FROM links WHERE favicon_url IS NULL OR favicon_url = ''`)
183 +
	if err != nil {
184 +
		return nil, err
185 +
	}
186 +
	defer rows.Close()
187 +
	var out []struct {
188 +
		ID  int64
189 +
		URL string
190 +
	}
191 +
	for rows.Next() {
192 +
		var r struct {
193 +
			ID  int64
194 +
			URL string
195 +
		}
196 +
		if err := rows.Scan(&r.ID, &r.URL); err != nil {
197 +
			return nil, err
198 +
		}
199 +
		out = append(out, r)
200 +
	}
201 +
	return out, rows.Err()
202 +
}
203 +
204 +
func updateLinkFavicon(db *sql.DB, id int64, favicon *string) error {
205 +
	var v any
206 +
	if favicon != nil && *favicon != "" {
207 +
		v = *favicon
208 +
	}
209 +
	_, err := db.Exec(`UPDATE links SET favicon_url = ? WHERE id = ?`, v, id)
210 +
	return err
211 +
}
212 +
213 +
func deleteLinkByShortID(db *sql.DB, shortID string) (bool, error) {
214 +
	res, err := db.Exec(`DELETE FROM links WHERE short_id = ?`, shortID)
215 +
	if err != nil {
216 +
		return false, err
217 +
	}
218 +
	n, _ := res.RowsAffected()
219 +
	return n > 0, nil
220 +
}
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) +35 −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/sqlite v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
11 +
	golang.org/x/net v0.41.0
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 +
	modernc.org/sqlite v1.37.1 // 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/sqlite => ../../crates-go/sqlite
34 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
35 +
)
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.WriteError(w, http.StatusNotFound, "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.WriteError(w, http.StatusBadRequest, "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.WriteError(w, http.StatusNotFound, "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) +73 −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 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
15 +
)
16 +
17 +
func main() {
18 +
	config.LoadDotEnv(".env")
19 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
20 +
21 +
	dbPath := config.Getenv("BOOKMARKS_DB_PATH", "bookmarks.sqlite")
22 +
	db, err := sqlite.Open(dbPath, schema)
23 +
	if err != nil {
24 +
		log.Fatal(err)
25 +
	}
26 +
	defer db.Close()
27 +
28 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
29 +
	if err := sessions.EnsureSchema(); err != nil {
30 +
		log.Fatal(err)
31 +
	}
32 +
	sessions.PruneExpired()
33 +
34 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
35 +
	app := &App{
36 +
		DB:           db,
37 +
		Log:          logger,
38 +
		Templates:    tmpl,
39 +
		Sessions:     sessions,
40 +
		Password:     os.Getenv("BOOKMARKS_PASSWORD"),
41 +
		APIKey:       os.Getenv("BOOKMARKS_API_KEY"),
42 +
		CookieSecure: sessions.CookieSecure,
43 +
	}
44 +
45 +
	go app.faviconBackfill(context.Background())
46 +
47 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
48 +
	logger.Info("bookmarks-go server running", "addr", addr)
49 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
50 +
		log.Fatal(err)
51 +
	}
52 +
}
53 +
54 +
func (a *App) faviconBackfill(ctx context.Context) {
55 +
	pending, err := listLinksMissingFavicon(a.DB)
56 +
	if err != nil {
57 +
		a.Log.Error("favicon backfill query", "err", err)
58 +
		return
59 +
	}
60 +
	if len(pending) == 0 {
61 +
		return
62 +
	}
63 +
	a.Log.Info("favicon backfill", "count", len(pending))
64 +
	for _, row := range pending {
65 +
		if fav := discoverFavicon(ctx, row.URL); fav != "" {
66 +
			if err := updateLinkFavicon(a.DB, row.ID, &fav); err != nil {
67 +
				a.Log.Error("favicon backfill update", "id", row.ID, "err", err)
68 +
			}
69 +
		}
70 +
		time.Sleep(250 * time.Millisecond)
71 +
	}
72 +
	a.Log.Info("favicon backfill done")
73 +
}
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) +184 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 +
)
9 +
10 +
const cellarSchema = `
11 +
CREATE TABLE IF NOT EXISTS wines (
12 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
13 +
    short_id        TEXT NOT NULL UNIQUE,
14 +
    name            TEXT NOT NULL,
15 +
    origin          TEXT NOT NULL,
16 +
    grape           TEXT NOT NULL,
17 +
    notes           TEXT NOT NULL,
18 +
    image           BLOB,
19 +
    image_mime      TEXT,
20 +
    sweetness       INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
21 +
    acidity         INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
22 +
    tannin          INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
23 +
    alcohol         INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
24 +
    body            INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
25 +
    clarity         INTEGER NOT NULL DEFAULT 3,
26 +
    color_intensity INTEGER NOT NULL DEFAULT 3,
27 +
    aroma_intensity INTEGER NOT NULL DEFAULT 3,
28 +
    nose_complexity INTEGER NOT NULL DEFAULT 3,
29 +
    background      TEXT NOT NULL DEFAULT '',
30 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
31 +
    wishlist        INTEGER NOT NULL DEFAULT 0
32 +
);
33 +
`
34 +
35 +
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`
36 +
37 +
func scanWine(s interface{ Scan(...any) error }) (*Wine, error) {
38 +
	var w Wine
39 +
	var hasImage int
40 +
	err := s.Scan(&w.ID, &w.ShortID, &w.Name, &w.Origin, &w.Grape, &w.Notes,
41 +
		&hasImage, &w.ImageMime,
42 +
		&w.Sweetness, &w.Acidity, &w.Tannin, &w.Alcohol, &w.Body,
43 +
		&w.Clarity, &w.ColorIntensity, &w.AromaIntensity, &w.NoseComplexity,
44 +
		&w.Background, &w.CreatedAt, &w.Wishlist)
45 +
	if errors.Is(err, sql.ErrNoRows) {
46 +
		return nil, nil
47 +
	}
48 +
	if err != nil {
49 +
		return nil, err
50 +
	}
51 +
	w.HasImage = hasImage != 0
52 +
	return &w, nil
53 +
}
54 +
55 +
func createWine(db *sql.DB, in WineInput, wishlist bool) (*Wine, error) {
56 +
	shortID, err := auth.GenerateShortID(10)
57 +
	if err != nil {
58 +
		return nil, err
59 +
	}
60 +
	res, err := db.Exec(
61 +
		`INSERT INTO wines (short_id, name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist)
62 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
63 +
		shortID, in.Name, in.Origin, in.Grape, in.Notes,
64 +
		in.Sweetness, in.Acidity, in.Tannin, in.Alcohol, in.Body,
65 +
		in.Clarity, in.ColorIntensity, in.AromaIntensity, in.NoseComplexity,
66 +
		in.Background, boolToInt(wishlist),
67 +
	)
68 +
	if err != nil {
69 +
		return nil, err
70 +
	}
71 +
	id, _ := res.LastInsertId()
72 +
	return scanWine(db.QueryRow(`SELECT `+wineCols+` FROM wines WHERE id = ?`, id))
73 +
}
74 +
75 +
func getCellarWines(db *sql.DB) ([]Wine, error) {
76 +
	rows, err := db.Query(`SELECT ` + wineCols + ` FROM wines WHERE wishlist = 0 ORDER BY id DESC`)
77 +
	if err != nil {
78 +
		return nil, err
79 +
	}
80 +
	defer rows.Close()
81 +
	out := []Wine{}
82 +
	for rows.Next() {
83 +
		w, err := scanWine(rows)
84 +
		if err != nil {
85 +
			return nil, err
86 +
		}
87 +
		out = append(out, *w)
88 +
	}
89 +
	return out, rows.Err()
90 +
}
91 +
92 +
func getWishlistWines(db *sql.DB) ([]Wine, error) {
93 +
	rows, err := db.Query(`SELECT ` + wineCols + ` FROM wines WHERE wishlist = 1 ORDER BY id DESC`)
94 +
	if err != nil {
95 +
		return nil, err
96 +
	}
97 +
	defer rows.Close()
98 +
	out := []Wine{}
99 +
	for rows.Next() {
100 +
		w, err := scanWine(rows)
101 +
		if err != nil {
102 +
			return nil, err
103 +
		}
104 +
		out = append(out, *w)
105 +
	}
106 +
	return out, rows.Err()
107 +
}
108 +
109 +
func getWineByShortID(db *sql.DB, shortID string) (*Wine, error) {
110 +
	return scanWine(db.QueryRow(`SELECT `+wineCols+` FROM wines WHERE short_id = ?`, shortID))
111 +
}
112 +
113 +
func getWineImage(db *sql.DB, shortID string) ([]byte, string, error) {
114 +
	var img []byte
115 +
	var mime string
116 +
	err := db.QueryRow(`SELECT image, COALESCE(image_mime, '') FROM wines WHERE short_id = ? AND image IS NOT NULL`, shortID).Scan(&img, &mime)
117 +
	if errors.Is(err, sql.ErrNoRows) {
118 +
		return nil, "", nil
119 +
	}
120 +
	if err != nil {
121 +
		return nil, "", err
122 +
	}
123 +
	return img, mime, nil
124 +
}
125 +
126 +
func updateWine(db *sql.DB, shortID string, in WineInput) (*Wine, error) {
127 +
	res, err := db.Exec(
128 +
		`UPDATE wines SET name = ?, origin = ?, grape = ?, notes = ?,
129 +
		 sweetness = ?, acidity = ?, tannin = ?, alcohol = ?, body = ?,
130 +
		 clarity = ?, color_intensity = ?, aroma_intensity = ?, nose_complexity = ?,
131 +
		 background = ? WHERE short_id = ?`,
132 +
		in.Name, in.Origin, in.Grape, in.Notes,
133 +
		in.Sweetness, in.Acidity, in.Tannin, in.Alcohol, in.Body,
134 +
		in.Clarity, in.ColorIntensity, in.AromaIntensity, in.NoseComplexity,
135 +
		in.Background, shortID,
136 +
	)
137 +
	if err != nil {
138 +
		return nil, err
139 +
	}
140 +
	if n, _ := res.RowsAffected(); n == 0 {
141 +
		return nil, nil
142 +
	}
143 +
	return getWineByShortID(db, shortID)
144 +
}
145 +
146 +
func updateWishlistWine(db *sql.DB, shortID, name, origin, grape, notes, background string) (*Wine, error) {
147 +
	res, err := db.Exec(
148 +
		`UPDATE wines SET name = ?, origin = ?, grape = ?, notes = ?, background = ? WHERE short_id = ? AND wishlist = 1`,
149 +
		name, origin, grape, notes, 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 promoteWine(db *sql.DB, shortID string) (bool, error) {
161 +
	res, err := db.Exec(`UPDATE wines SET wishlist = 0 WHERE short_id = ? AND wishlist = 1`, shortID)
162 +
	if err != nil {
163 +
		return false, err
164 +
	}
165 +
	n, _ := res.RowsAffected()
166 +
	return n > 0, nil
167 +
}
168 +
169 +
func updateWineImage(db *sql.DB, shortID string, image []byte, mime string) error {
170 +
	_, err := db.Exec(`UPDATE wines SET image = ?, image_mime = ? WHERE short_id = ?`, image, mime, shortID)
171 +
	return err
172 +
}
173 +
174 +
func deleteWine(db *sql.DB, shortID string) error {
175 +
	_, err := db.Exec(`DELETE FROM wines WHERE short_id = ?`, shortID)
176 +
	return err
177 +
}
178 +
179 +
func boolToInt(b bool) int {
180 +
	if b {
181 +
		return 1
182 +
	}
183 +
	return 0
184 +
}
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) +34 −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/sqlite v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
11 +
)
12 +
13 +
require (
14 +
	github.com/dustin/go-humanize v1.0.1 // indirect
15 +
	github.com/google/uuid v1.6.0 // indirect
16 +
	github.com/mattn/go-isatty v0.0.20 // indirect
17 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
18 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
19 +
	golang.org/x/crypto v0.39.0 // indirect
20 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
21 +
	golang.org/x/sys v0.33.0 // indirect
22 +
	modernc.org/libc v1.65.7 // indirect
23 +
	modernc.org/mathutil v1.7.1 // indirect
24 +
	modernc.org/memory v1.11.0 // indirect
25 +
	modernc.org/sqlite v1.37.1 // indirect
26 +
)
27 +
28 +
replace (
29 +
	github.com/stevedylandev/andromeda/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/sqlite => ../../crates-go/sqlite
33 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
34 +
)
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.WriteError(w, http.StatusBadRequest, "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.WriteError(w, http.StatusBadRequest, err.Error())
216 +
		return
217 +
	}
218 +
	file, header, err := r.FormFile("image")
219 +
	if err != nil {
220 +
		web.WriteError(w, http.StatusBadRequest, "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.WriteError(w, http.StatusBadRequest, "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.WriteError(w, http.StatusInternalServerError, 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) +58 −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 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
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("CELLAR_DB_PATH", "cellar.sqlite")
21 +
	db, err := sqlite.Open(dbPath, cellarSchema)
22 +
	if err != nil {
23 +
		log.Fatal(err)
24 +
	}
25 +
	defer db.Close()
26 +
27 +
	password := os.Getenv("CELLAR_PASSWORD")
28 +
	if password == "" {
29 +
		logger.Warn("CELLAR_PASSWORD not set, using default 'changeme'")
30 +
		password = "changeme"
31 +
	}
32 +
33 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
34 +
	if err := sessions.EnsureSchema(); err != nil {
35 +
		log.Fatal(err)
36 +
	}
37 +
	sessions.PruneExpired()
38 +
39 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
40 +
	app := &App{
41 +
		DB:              db,
42 +
		Log:             logger,
43 +
		Templates:       tmpl,
44 +
		Sessions:        sessions,
45 +
		AppPassword:     password,
46 +
		CookieSecure:    sessions.CookieSecure,
47 +
		AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
48 +
		SiteURL:         strings.TrimRight(config.Getenv("SITE_URL", "http://localhost:3000"), "/"),
49 +
		SiteTitle:       config.Getenv("SITE_TITLE", "Cellar"),
50 +
		SiteDescription: config.Getenv("SITE_DESCRIPTION", "Personal wine tasting log"),
51 +
	}
52 +
53 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
54 +
	logger.Info("cellar-go server running", "addr", addr)
55 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
56 +
		log.Fatal(err)
57 +
	}
58 +
}
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) +118 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
)
7 +
8 +
const easelSchema = `
9 +
CREATE TABLE IF NOT EXISTS daily_artworks (
10 +
    date              TEXT PRIMARY KEY,
11 +
    artwork_id        INTEGER NOT NULL,
12 +
    title             TEXT NOT NULL,
13 +
    artist_display    TEXT,
14 +
    artist_title      TEXT,
15 +
    date_display      TEXT,
16 +
    medium_display    TEXT,
17 +
    dimensions        TEXT,
18 +
    place_of_origin   TEXT,
19 +
    credit_line       TEXT,
20 +
    description       TEXT,
21 +
    short_description TEXT,
22 +
    image_id          TEXT NOT NULL,
23 +
    fetched_at        TEXT NOT NULL DEFAULT (datetime('now'))
24 +
);
25 +
CREATE INDEX IF NOT EXISTS idx_daily_artworks_artwork_id ON daily_artworks(artwork_id);
26 +
`
27 +
28 +
type DailyArtwork struct {
29 +
	Date             string
30 +
	ArtworkID        int64
31 +
	Title            string
32 +
	ArtistDisplay    sql.NullString
33 +
	ArtistTitle      sql.NullString
34 +
	DateDisplay      sql.NullString
35 +
	MediumDisplay    sql.NullString
36 +
	Dimensions       sql.NullString
37 +
	PlaceOfOrigin    sql.NullString
38 +
	CreditLine       sql.NullString
39 +
	Description      sql.NullString
40 +
	ShortDescription sql.NullString
41 +
	ImageID          string
42 +
	FetchedAt        string
43 +
}
44 +
45 +
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`
46 +
47 +
func scanDaily(s interface{ Scan(...any) error }) (*DailyArtwork, error) {
48 +
	var d DailyArtwork
49 +
	err := s.Scan(&d.Date, &d.ArtworkID, &d.Title, &d.ArtistDisplay, &d.ArtistTitle,
50 +
		&d.DateDisplay, &d.MediumDisplay, &d.Dimensions, &d.PlaceOfOrigin, &d.CreditLine,
51 +
		&d.Description, &d.ShortDescription, &d.ImageID, &d.FetchedAt)
52 +
	if errors.Is(err, sql.ErrNoRows) {
53 +
		return nil, nil
54 +
	}
55 +
	if err != nil {
56 +
		return nil, err
57 +
	}
58 +
	return &d, nil
59 +
}
60 +
61 +
func insertDaily(db *sql.DB, a *DailyArtwork) (bool, error) {
62 +
	res, err := db.Exec(
63 +
		`INSERT OR IGNORE INTO daily_artworks (`+dailyCols+`)
64 +
		 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
65 +
		a.Date, a.ArtworkID, a.Title, a.ArtistDisplay, a.ArtistTitle,
66 +
		a.DateDisplay, a.MediumDisplay, a.Dimensions, a.PlaceOfOrigin, a.CreditLine,
67 +
		a.Description, a.ShortDescription, a.ImageID, a.FetchedAt,
68 +
	)
69 +
	if err != nil {
70 +
		return false, err
71 +
	}
72 +
	n, _ := res.RowsAffected()
73 +
	return n > 0, nil
74 +
}
75 +
76 +
func getDaily(db *sql.DB, date string) (*DailyArtwork, error) {
77 +
	return scanDaily(db.QueryRow(`SELECT `+dailyCols+` FROM daily_artworks WHERE date = ?`, date))
78 +
}
79 +
80 +
func listDaily(db *sql.DB, limit int) ([]DailyArtwork, error) {
81 +
	rows, err := db.Query(`SELECT `+dailyCols+` FROM daily_artworks ORDER BY date DESC LIMIT ?`, limit)
82 +
	if err != nil {
83 +
		return nil, err
84 +
	}
85 +
	defer rows.Close()
86 +
	var out []DailyArtwork
87 +
	for rows.Next() {
88 +
		d, err := scanDaily(rows)
89 +
		if err != nil {
90 +
			return nil, err
91 +
		}
92 +
		out = append(out, *d)
93 +
	}
94 +
	return out, rows.Err()
95 +
}
96 +
97 +
func artworkIDExists(db *sql.DB, id int64) (bool, error) {
98 +
	var n int64
99 +
	err := db.QueryRow(`SELECT COUNT(*) FROM daily_artworks WHERE artwork_id = ?`, id).Scan(&n)
100 +
	if err != nil {
101 +
		return false, err
102 +
	}
103 +
	return n > 0, nil
104 +
}
105 +
106 +
func missingDates(db *sql.DB, dates []string) ([]string, error) {
107 +
	out := []string{}
108 +
	for _, d := range dates {
109 +
		var n int64
110 +
		if err := db.QueryRow(`SELECT COUNT(*) FROM daily_artworks WHERE date = ?`, d).Scan(&n); err != nil {
111 +
			return nil, err
112 +
		}
113 +
		if n == 0 {
114 +
			out = append(out, d)
115 +
		}
116 +
	}
117 +
	return out, nil
118 +
}
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) +31 −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/sqlite v0.0.0
9 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
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 +
	modernc.org/sqlite v1.37.1 // indirect
24 +
)
25 +
26 +
replace (
27 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
28 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
29 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
30 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
31 +
)
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.WriteError(w, http.StatusNotFound, "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.WriteError(w, http.StatusBadRequest, "invalid date format")
83 +
		return
84 +
	}
85 +
	if date > a.todayInTZ() {
86 +
		web.WriteError(w, http.StatusNotFound, "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.WriteError(w, http.StatusNotFound, "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) +75 −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 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
15 +
)
16 +
17 +
func splitCommaTrim(s string) []string {
18 +
	out := []string{}
19 +
	for _, part := range strings.Split(s, ",") {
20 +
		if v := strings.TrimSpace(part); v != "" {
21 +
			out = append(out, v)
22 +
		}
23 +
	}
24 +
	return out
25 +
}
26 +
27 +
func main() {
28 +
	config.LoadDotEnv(".env")
29 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
30 +
31 +
	dbPath := config.Getenv("EASEL_DB_PATH", "easel.sqlite")
32 +
	db, err := sqlite.Open(dbPath, easelSchema)
33 +
	if err != nil {
34 +
		log.Fatal(err)
35 +
	}
36 +
	defer db.Close()
37 +
38 +
	tzName := config.Getenv("EASEL_TIMEZONE", "UTC")
39 +
	loc, err := time.LoadLocation(tzName)
40 +
	if err != nil {
41 +
		logger.Warn("invalid EASEL_TIMEZONE, falling back to UTC", "value", tzName)
42 +
		loc = time.UTC
43 +
		tzName = "UTC"
44 +
	}
45 +
46 +
	classifications := splitCommaTrim(config.Getenv("EASEL_CLASSIFICATIONS", "painting"))
47 +
	if len(classifications) == 0 {
48 +
		log.Fatal("EASEL_CLASSIFICATIONS resolved to empty list")
49 +
	}
50 +
	excludeTerms := splitCommaTrim(config.Getenv("EASEL_EXCLUDE_TERMS", "erotic,erotica,shunga"))
51 +
52 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
53 +
	app := &App{
54 +
		DB:              db,
55 +
		Log:             logger,
56 +
		Templates:       tmpl,
57 +
		HTTP:            buildHTTPClient(),
58 +
		TZ:              loc,
59 +
		TZName:          tzName,
60 +
		Classifications: classifications,
61 +
		ExcludeTerms:    excludeTerms,
62 +
		BackfillDays:    config.GetenvInt("EASEL_BACKFILL_DAYS", 0),
63 +
		MaxDedupRetries: config.GetenvInt("EASEL_MAX_DEDUP_RETRIES", 10),
64 +
		BaseURL:         strings.TrimRight(config.Getenv("EASEL_BASE_URL", "http://localhost:4242"), "/"),
65 +
	}
66 +
	logger.Info("easel-go starting", "tz", tzName, "classifications", classifications, "exclude_terms", excludeTerms, "backfill_days", app.BackfillDays, "retries", app.MaxDedupRetries)
67 +
68 +
	go app.runScheduler(context.Background())
69 +
70 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "4242")
71 +
	logger.Info("easel-go server running", "addr", addr)
72 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
73 +
		log.Fatal(err)
74 +
	}
75 +
}
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/.env.example (added) +9 −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 +
FEEDS_DB_PATH=/data/feeds-go.sqlite
7 +
API_KEY=
8 +
DEFAULT_POLL_MINUTES=30
9 +
ITEM_CAP_PER_FEED=200
apps/feeds-go/.gitignore (added) +2 −0
1 +
feeds-go.sqlite
2 +
.env
apps/feeds-go/Dockerfile (added) +14 −0
1 +
FROM golang:1.24-bookworm AS builder
2 +
WORKDIR /app
3 +
COPY apps/feeds-go/go.mod apps/feeds-go/go.sum ./
4 +
RUN go mod download
5 +
COPY apps/feeds-go/ ./
6 +
RUN CGO_ENABLED=0 go build -o /feeds-go .
7 +
8 +
FROM debian:bookworm-slim
9 +
COPY --from=builder /feeds-go /usr/local/bin/feeds-go
10 +
WORKDIR /data
11 +
ENV HOST=0.0.0.0
12 +
ENV PORT=3000
13 +
EXPOSE 3000
14 +
CMD ["feeds-go"]
apps/feeds-go/README.md (added) +32 −0
1 +
# Feeds Go
2 +
3 +
A Go rewrite of `apps/feeds` using mostly the Go standard library plus a SQLite driver and a feed parser.
4 +
5 +
## Stack
6 +
7 +
- `net/http`
8 +
- `html/template`
9 +
- `database/sql`
10 +
- `embed`
11 +
- `modernc.org/sqlite`
12 +
- `github.com/mmcdole/gofeed`
13 +
14 +
## Run
15 +
16 +
```bash
17 +
cd apps/feeds-go
18 +
go run .
19 +
```
20 +
21 +
Copy `.env.example` to `.env` if you want local config.
22 +
23 +
## What it includes
24 +
25 +
- public feed list
26 +
- preview mode via `?url=` / `?urls=`
27 +
- admin login with cookie sessions
28 +
- add/remove subscriptions and categories
29 +
- OPML import
30 +
- JSON API
31 +
- background polling with ETag / Last-Modified
32 +
- embedded templates and static assets
apps/feeds-go/app.go (added) +109 −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 +
	AdminPassword      string
21 +
	APIKey             string
22 +
	CookieSecure       bool
23 +
	BaseURL            string
24 +
	DefaultPollMinutes int
25 +
	ItemCap            int
26 +
}
27 +
28 +
type templateItem struct {
29 +
	Title         string
30 +
	Link          string
31 +
	Author        string
32 +
	FormattedDate string
33 +
}
34 +
35 +
type adminSubRow struct {
36 +
	ID            int64
37 +
	Title         string
38 +
	FeedURL       string
39 +
	SiteURL       string
40 +
	CategoryName  string
41 +
	LastFetchedAt string
42 +
	LastError     string
43 +
}
44 +
45 +
type indexPageData struct {
46 +
	BaseURL  string
47 +
	Items    []templateItem
48 +
	FeedURLs []string
49 +
	Error    string
50 +
}
51 +
52 +
type loginPageData struct {
53 +
	Error string
54 +
}
55 +
56 +
type adminPageData struct {
57 +
	Success             string
58 +
	Error               string
59 +
	Subscriptions       []adminSubRow
60 +
	Categories          []Category
61 +
	PollIntervalMinutes int
62 +
	ItemCap             int
63 +
	APIKeyConfigured    bool
64 +
}
65 +
66 +
type createSubscriptionBody struct {
67 +
	FeedURL      string `json:"feed_url"`
68 +
	Title        string `json:"title"`
69 +
	CategoryID   *int64 `json:"category_id"`
70 +
	CategoryName string `json:"category_name"`
71 +
}
72 +
73 +
type updateSubscriptionBody struct {
74 +
	CategoryID    *int64 `json:"category_id"`
75 +
	CategoryName  string `json:"category_name"`
76 +
	ClearCategory bool   `json:"clear_category"`
77 +
}
78 +
79 +
type createCategoryBody struct {
80 +
	Name string `json:"name"`
81 +
}
82 +
83 +
type updateSettingsBody struct {
84 +
	PollIntervalMinutes *int `json:"poll_interval_minutes"`
85 +
}
86 +
87 +
type discoverBody struct {
88 +
	BaseURL string `json:"base_url"`
89 +
}
90 +
91 +
type importSummary struct {
92 +
	Imported int      `json:"imported"`
93 +
	Skipped  int      `json:"skipped"`
94 +
	Failed   []string `json:"failed"`
95 +
}
96 +
97 +
type subscriptionView struct {
98 +
	ID            int64   `json:"id"`
99 +
	FeedURL       string  `json:"feed_url"`
100 +
	Title         string  `json:"title"`
101 +
	SiteURL       *string `json:"site_url,omitempty"`
102 +
	FaviconURL    *string `json:"favicon_url,omitempty"`
103 +
	CategoryID    *int64  `json:"category_id,omitempty"`
104 +
	ETag          *string `json:"etag,omitempty"`
105 +
	LastModified  *string `json:"last_modified,omitempty"`
106 +
	LastFetchedAt *string `json:"last_fetched_at,omitempty"`
107 +
	LastError     *string `json:"last_error,omitempty"`
108 +
	AddedAt       string  `json:"added_at"`
109 +
}
apps/feeds-go/db.go (added) +96 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"fmt"
7 +
	"strings"
8 +
)
9 +
10 +
const feedsSchema = `
11 +
CREATE TABLE IF NOT EXISTS categories (
12 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
13 +
    name       TEXT NOT NULL UNIQUE,
14 +
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
15 +
);
16 +
17 +
CREATE TABLE IF NOT EXISTS subscriptions (
18 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
19 +
    feed_url        TEXT NOT NULL UNIQUE,
20 +
    title           TEXT NOT NULL,
21 +
    site_url        TEXT,
22 +
    favicon_url     TEXT,
23 +
    category_id     INTEGER REFERENCES categories(id) ON DELETE SET NULL,
24 +
    etag            TEXT,
25 +
    last_modified   TEXT,
26 +
    last_fetched_at TEXT,
27 +
    last_error      TEXT,
28 +
    added_at        TEXT NOT NULL DEFAULT (datetime('now'))
29 +
);
30 +
CREATE INDEX IF NOT EXISTS idx_subs_category ON subscriptions(category_id);
31 +
32 +
CREATE TABLE IF NOT EXISTS items (
33 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
34 +
    subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
35 +
    guid            TEXT NOT NULL,
36 +
    title           TEXT NOT NULL,
37 +
    link            TEXT NOT NULL,
38 +
    author          TEXT,
39 +
    published_at    INTEGER NOT NULL,
40 +
    is_read         INTEGER NOT NULL DEFAULT 0,
41 +
    fetched_at      TEXT NOT NULL DEFAULT (datetime('now')),
42 +
    UNIQUE(subscription_id, guid)
43 +
);
44 +
CREATE INDEX IF NOT EXISTS idx_items_sub_pub ON items(subscription_id, published_at DESC);
45 +
CREATE INDEX IF NOT EXISTS idx_items_pub ON items(published_at DESC);
46 +
CREATE INDEX IF NOT EXISTS idx_items_unread ON items(is_read, published_at DESC);
47 +
48 +
CREATE TABLE IF NOT EXISTS settings (
49 +
    key   TEXT PRIMARY KEY,
50 +
    value TEXT NOT NULL
51 +
);
52 +
`
53 +
54 +
func seedSettings(db *sql.DB, defaultPoll int) error {
55 +
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('poll_interval_minutes', ?)
56 +
		ON CONFLICT(key) DO NOTHING`, fmt.Sprintf("%d", defaultPoll))
57 +
	return err
58 +
}
59 +
60 +
func getSetting(db *sql.DB, key string) (string, bool, error) {
61 +
	var value string
62 +
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
63 +
	if errors.Is(err, sql.ErrNoRows) {
64 +
		return "", false, nil
65 +
	}
66 +
	if err != nil {
67 +
		return "", false, err
68 +
	}
69 +
	return value, true, nil
70 +
}
71 +
72 +
func setSetting(db *sql.DB, key, value string) error {
73 +
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
74 +
	return err
75 +
}
76 +
77 +
func nullableString(s *string) any {
78 +
	if s == nil || strings.TrimSpace(*s) == "" {
79 +
		return nil
80 +
	}
81 +
	return *s
82 +
}
83 +
84 +
func nullableInt64(v *int64) any {
85 +
	if v == nil {
86 +
		return nil
87 +
	}
88 +
	return *v
89 +
}
90 +
91 +
func stringPtr(s string) *string {
92 +
	if strings.TrimSpace(s) == "" {
93 +
		return nil
94 +
	}
95 +
	return &s
96 +
}
apps/feeds-go/db_categories.go (added) +76 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
)
8 +
9 +
type Category struct {
10 +
	ID        int64
11 +
	Name      string
12 +
	CreatedAt string
13 +
}
14 +
15 +
func listCategories(db *sql.DB) ([]Category, error) {
16 +
	rows, err := db.Query(`SELECT id, name, created_at FROM categories ORDER BY name ASC`)
17 +
	if err != nil {
18 +
		return nil, err
19 +
	}
20 +
	defer rows.Close()
21 +
	var out []Category
22 +
	for rows.Next() {
23 +
		var c Category
24 +
		if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt); err != nil {
25 +
			return nil, err
26 +
		}
27 +
		out = append(out, c)
28 +
	}
29 +
	return out, rows.Err()
30 +
}
31 +
32 +
func getOrCreateCategory(db *sql.DB, name string) (*Category, error) {
33 +
	name = strings.TrimSpace(name)
34 +
	if name == "" {
35 +
		return nil, nil
36 +
	}
37 +
	var c Category
38 +
	err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&c.ID, &c.Name, &c.CreatedAt)
39 +
	if err == nil {
40 +
		return &c, nil
41 +
	}
42 +
	if !errors.Is(err, sql.ErrNoRows) {
43 +
		return nil, err
44 +
	}
45 +
	res, err := db.Exec(`INSERT INTO categories (name) VALUES (?)`, name)
46 +
	if err != nil {
47 +
		var existing Category
48 +
		if err2 := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&existing.ID, &existing.Name, &existing.CreatedAt); err2 == nil {
49 +
			return &existing, nil
50 +
		}
51 +
		return nil, err
52 +
	}
53 +
	id, _ := res.LastInsertId()
54 +
	return getCategory(db, id)
55 +
}
56 +
57 +
func getCategory(db *sql.DB, id int64) (*Category, error) {
58 +
	var c Category
59 +
	err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE id = ?`, id).Scan(&c.ID, &c.Name, &c.CreatedAt)
60 +
	if errors.Is(err, sql.ErrNoRows) {
61 +
		return nil, nil
62 +
	}
63 +
	if err != nil {
64 +
		return nil, err
65 +
	}
66 +
	return &c, nil
67 +
}
68 +
69 +
func deleteCategory(db *sql.DB, id int64) (bool, error) {
70 +
	res, err := db.Exec(`DELETE FROM categories WHERE id = ?`, id)
71 +
	if err != nil {
72 +
		return false, err
73 +
	}
74 +
	n, _ := res.RowsAffected()
75 +
	return n > 0, nil
76 +
}
apps/feeds-go/db_items.go (added) +131 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"strings"
6 +
)
7 +
8 +
type ItemWithFeed struct {
9 +
	ID             int64   `json:"id"`
10 +
	SubscriptionID int64   `json:"subscription_id"`
11 +
	GUID           string  `json:"guid"`
12 +
	Title          string  `json:"title"`
13 +
	Link           string  `json:"link"`
14 +
	Author         *string `json:"author,omitempty"`
15 +
	PublishedAt    int64   `json:"published_at"`
16 +
	IsRead         bool    `json:"is_read"`
17 +
	FetchedAt      string  `json:"fetched_at"`
18 +
	FeedTitle      string  `json:"feed_title"`
19 +
	FeedURL        string  `json:"feed_url"`
20 +
	CategoryID     *int64  `json:"category_id,omitempty"`
21 +
	CategoryName   *string `json:"category_name,omitempty"`
22 +
}
23 +
24 +
type ListItemsFilter struct {
25 +
	Limit          int
26 +
	UnreadOnly     bool
27 +
	CategoryID     *int64
28 +
	SubscriptionID *int64
29 +
}
30 +
31 +
type NewItem struct {
32 +
	SubscriptionID int64
33 +
	GUID           string
34 +
	Title          string
35 +
	Link           string
36 +
	Author         string
37 +
	PublishedAt    int64
38 +
}
39 +
40 +
func listItems(db *sql.DB, filter ListItemsFilter) ([]ItemWithFeed, error) {
41 +
	limit := filter.Limit
42 +
	if limit <= 0 {
43 +
		limit = 100
44 +
	}
45 +
	if limit > 1000 {
46 +
		limit = 1000
47 +
	}
48 +
	var b strings.Builder
49 +
	b.WriteString(`SELECT i.id, i.subscription_id, i.guid, i.title, i.link, i.author, i.published_at,
50 +
		i.is_read, i.fetched_at, s.title, s.feed_url, s.category_id, c.name
51 +
		FROM items i
52 +
		JOIN subscriptions s ON s.id = i.subscription_id
53 +
		LEFT JOIN categories c ON c.id = s.category_id
54 +
		WHERE 1=1`)
55 +
	args := []any{}
56 +
	if filter.UnreadOnly {
57 +
		b.WriteString(" AND i.is_read = 0")
58 +
	}
59 +
	if filter.CategoryID != nil {
60 +
		b.WriteString(" AND s.category_id = ?")
61 +
		args = append(args, *filter.CategoryID)
62 +
	}
63 +
	if filter.SubscriptionID != nil {
64 +
		b.WriteString(" AND i.subscription_id = ?")
65 +
		args = append(args, *filter.SubscriptionID)
66 +
	}
67 +
	b.WriteString(" ORDER BY i.published_at DESC, i.id DESC LIMIT ?")
68 +
	args = append(args, limit)
69 +
	rows, err := db.Query(b.String(), args...)
70 +
	if err != nil {
71 +
		return nil, err
72 +
	}
73 +
	defer rows.Close()
74 +
	var items []ItemWithFeed
75 +
	for rows.Next() {
76 +
		var it ItemWithFeed
77 +
		var author sql.NullString
78 +
		var categoryID sql.NullInt64
79 +
		var categoryName sql.NullString
80 +
		var isRead int
81 +
		if err := rows.Scan(&it.ID, &it.SubscriptionID, &it.GUID, &it.Title, &it.Link, &author, &it.PublishedAt, &isRead, &it.FetchedAt, &it.FeedTitle, &it.FeedURL, &categoryID, &categoryName); err != nil {
82 +
			return nil, err
83 +
		}
84 +
		if author.Valid {
85 +
			it.Author = &author.String
86 +
		}
87 +
		if categoryID.Valid {
88 +
			v := categoryID.Int64
89 +
			it.CategoryID = &v
90 +
		}
91 +
		if categoryName.Valid {
92 +
			v := categoryName.String
93 +
			it.CategoryName = &v
94 +
		}
95 +
		it.IsRead = isRead != 0
96 +
		items = append(items, it)
97 +
	}
98 +
	return items, rows.Err()
99 +
}
100 +
101 +
func insertItemIgnoreDup(db *sql.DB, item NewItem) (bool, error) {
102 +
	res, err := db.Exec(`INSERT OR IGNORE INTO items (subscription_id, guid, title, link, author, published_at) VALUES (?, ?, ?, ?, ?, ?)`,
103 +
		item.SubscriptionID, item.GUID, item.Title, item.Link, nullableString(stringPtr(strings.TrimSpace(item.Author))), item.PublishedAt)
104 +
	if err != nil {
105 +
		return false, err
106 +
	}
107 +
	n, _ := res.RowsAffected()
108 +
	return n > 0, nil
109 +
}
110 +
111 +
func pruneSubscription(db *sql.DB, subscriptionID int64, keepN int) error {
112 +
	_, err := db.Exec(`DELETE FROM items
113 +
		WHERE subscription_id = ?
114 +
		  AND id NOT IN (
115 +
			SELECT id FROM items WHERE subscription_id = ? ORDER BY published_at DESC, id DESC LIMIT ?
116 +
		)`, subscriptionID, subscriptionID, keepN)
117 +
	return err
118 +
}
119 +
120 +
func markItemRead(db *sql.DB, id int64, isRead bool) (bool, error) {
121 +
	val := 0
122 +
	if isRead {
123 +
		val = 1
124 +
	}
125 +
	res, err := db.Exec(`UPDATE items SET is_read = ? WHERE id = ?`, val, id)
126 +
	if err != nil {
127 +
		return false, err
128 +
	}
129 +
	n, _ := res.RowsAffected()
130 +
	return n > 0, nil
131 +
}
apps/feeds-go/db_subscriptions.go (added) +112 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"time"
7 +
)
8 +
9 +
const subscriptionSelectColumns = `id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at`
10 +
11 +
type Subscription struct {
12 +
	ID            int64
13 +
	FeedURL       string
14 +
	Title         string
15 +
	SiteURL       sql.NullString
16 +
	FaviconURL    sql.NullString
17 +
	CategoryID    sql.NullInt64
18 +
	ETag          sql.NullString
19 +
	LastModified  sql.NullString
20 +
	LastFetchedAt sql.NullString
21 +
	LastError     sql.NullString
22 +
	AddedAt       string
23 +
}
24 +
25 +
func listSubscriptions(db *sql.DB) ([]Subscription, error) {
26 +
	rows, err := db.Query(`SELECT ` + subscriptionSelectColumns + `
27 +
		FROM subscriptions ORDER BY title COLLATE NOCASE ASC`)
28 +
	if err != nil {
29 +
		return nil, err
30 +
	}
31 +
	defer rows.Close()
32 +
	var subs []Subscription
33 +
	for rows.Next() {
34 +
		s, err := scanSubscription(rows)
35 +
		if err != nil {
36 +
			return nil, err
37 +
		}
38 +
		subs = append(subs, *s)
39 +
	}
40 +
	return subs, rows.Err()
41 +
}
42 +
43 +
func getSubscriptionByURL(db *sql.DB, feedURL string) (*Subscription, error) {
44 +
	return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE feed_url = ?`, feedURL)
45 +
}
46 +
47 +
func insertSubscription(db *sql.DB, feedURL, title string, siteURL *string, categoryID *int64) (*Subscription, error) {
48 +
	res, err := db.Exec(`INSERT INTO subscriptions (feed_url, title, site_url, category_id) VALUES (?, ?, ?, ?)`, feedURL, title, siteURL, categoryID)
49 +
	if err != nil {
50 +
		return nil, err
51 +
	}
52 +
	id, err := res.LastInsertId()
53 +
	if err != nil {
54 +
		return nil, err
55 +
	}
56 +
	return getSubscription(db, id)
57 +
}
58 +
59 +
func getSubscription(db *sql.DB, id int64) (*Subscription, error) {
60 +
	return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE id = ?`, id)
61 +
}
62 +
63 +
func updateSubscriptionMeta(db *sql.DB, id int64, etag, lastModified *string, lastError *string) error {
64 +
	_, err := db.Exec(`UPDATE subscriptions SET etag = ?, last_modified = ?, last_fetched_at = ?, last_error = ? WHERE id = ?`,
65 +
		nullableString(etag), nullableString(lastModified), time.Now().UTC().Format("2006-01-02 15:04:05"), nullableString(lastError), id)
66 +
	return err
67 +
}
68 +
69 +
func updateSubscriptionTitle(db *sql.DB, id int64, title string) error {
70 +
	_, err := db.Exec(`UPDATE subscriptions SET title = ? WHERE id = ?`, title, id)
71 +
	return err
72 +
}
73 +
74 +
func updateSubscriptionSiteURL(db *sql.DB, id int64, siteURL *string) error {
75 +
	_, err := db.Exec(`UPDATE subscriptions SET site_url = ? WHERE id = ?`, nullableString(siteURL), id)
76 +
	return err
77 +
}
78 +
79 +
func updateSubscriptionFavicon(db *sql.DB, id int64, favicon *string) error {
80 +
	_, err := db.Exec(`UPDATE subscriptions SET favicon_url = ? WHERE id = ?`, nullableString(favicon), id)
81 +
	return err
82 +
}
83 +
84 +
func updateSubscriptionCategory(db *sql.DB, id int64, categoryID *int64) error {
85 +
	_, err := db.Exec(`UPDATE subscriptions SET category_id = ? WHERE id = ?`, nullableInt64(categoryID), id)
86 +
	return err
87 +
}
88 +
89 +
func deleteSubscription(db *sql.DB, id int64) (bool, error) {
90 +
	res, err := db.Exec(`DELETE FROM subscriptions WHERE id = ?`, id)
91 +
	if err != nil {
92 +
		return false, err
93 +
	}
94 +
	n, _ := res.RowsAffected()
95 +
	return n > 0, nil
96 +
}
97 +
98 +
func querySubscription(db *sql.DB, query string, args ...any) (*Subscription, error) {
99 +
	return scanSubscription(db.QueryRow(query, args...))
100 +
}
101 +
102 +
func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*Subscription, error) {
103 +
	var s Subscription
104 +
	err := scanner.Scan(&s.ID, &s.FeedURL, &s.Title, &s.SiteURL, &s.FaviconURL, &s.CategoryID, &s.ETag, &s.LastModified, &s.LastFetchedAt, &s.LastError, &s.AddedAt)
105 +
	if errors.Is(err, sql.ErrNoRows) {
106 +
		return nil, nil
107 +
	}
108 +
	if err != nil {
109 +
		return nil, err
110 +
	}
111 +
	return &s, nil
112 +
}
apps/feeds-go/docker-compose.yml (added) +23 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/feeds-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
10 +
      - FEEDS_DB_PATH=/data/feeds-go.sqlite
11 +
      - COOKIE_SECURE=false
12 +
      - HOST=0.0.0.0
13 +
      - PORT=${PORT:-3000}
14 +
      - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}}
15 +
      - API_KEY=${API_KEY:-}
16 +
      - DEFAULT_POLL_MINUTES=${DEFAULT_POLL_MINUTES:-30}
17 +
      - ITEM_CAP_PER_FEED=${ITEM_CAP_PER_FEED:-200}
18 +
    volumes:
19 +
      - feeds-go-data:/data
20 +
    restart: unless-stopped
21 +
22 +
volumes:
23 +
  feeds-go-data:
apps/feeds-go/feeds.go (added) +377 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"errors"
6 +
	"fmt"
7 +
	"io"
8 +
	"log/slog"
9 +
	"net/http"
10 +
	"net/url"
11 +
	"slices"
12 +
	"strings"
13 +
	"sync"
14 +
	"time"
15 +
	"unicode/utf8"
16 +
17 +
	"github.com/mmcdole/gofeed"
18 +
	"golang.org/x/net/html"
19 +
)
20 +
21 +
type ParsedEntry struct {
22 +
	GUID        string
23 +
	Title       string
24 +
	Link        string
25 +
	Author      string
26 +
	PublishedAt int64
27 +
}
28 +
29 +
type FetchResult struct {
30 +
	Status       int
31 +
	ETag         string
32 +
	LastModified string
33 +
	Title        string
34 +
	SiteURL      string
35 +
	Entries      []ParsedEntry
36 +
}
37 +
38 +
type FeedPreviewItem struct {
39 +
	Title     string
40 +
	Link      string
41 +
	Author    string
42 +
	Published int64
43 +
}
44 +
45 +
const appUserAgent = "andromeda-feeds-go/0.1 (+https://github.com/stevedylandev/andromeda)"
46 +
47 +
func buildHTTPClient() *http.Client {
48 +
	return &http.Client{Timeout: 15 * time.Second}
49 +
}
50 +
51 +
func newRequest(ctx context.Context, method, rawURL string) (*http.Request, error) {
52 +
	req, err := http.NewRequestWithContext(ctx, method, rawURL, nil)
53 +
	if err != nil {
54 +
		return nil, err
55 +
	}
56 +
	req.Header.Set("User-Agent", appUserAgent)
57 +
	return req, nil
58 +
}
59 +
60 +
func fetchFeed(ctx context.Context, feedURL, etag, lastModified string) (*FetchResult, error) {
61 +
	client := buildHTTPClient()
62 +
	req, err := newRequest(ctx, http.MethodGet, feedURL)
63 +
	if err != nil {
64 +
		return nil, err
65 +
	}
66 +
	if etag != "" {
67 +
		req.Header.Set("If-None-Match", etag)
68 +
	}
69 +
	if lastModified != "" {
70 +
		req.Header.Set("If-Modified-Since", lastModified)
71 +
	}
72 +
	resp, err := client.Do(req)
73 +
	if err != nil {
74 +
		return nil, fmt.Errorf("fetch failed: %w", err)
75 +
	}
76 +
	defer resp.Body.Close()
77 +
	result := &FetchResult{
78 +
		Status:       resp.StatusCode,
79 +
		ETag:         resp.Header.Get("ETag"),
80 +
		LastModified: resp.Header.Get("Last-Modified"),
81 +
	}
82 +
	if resp.StatusCode == http.StatusNotModified {
83 +
		if result.ETag == "" {
84 +
			result.ETag = etag
85 +
		}
86 +
		if result.LastModified == "" {
87 +
			result.LastModified = lastModified
88 +
		}
89 +
		return result, nil
90 +
	}
91 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
92 +
		return nil, fmt.Errorf("upstream returned %d", resp.StatusCode)
93 +
	}
94 +
	parser := gofeed.NewParser()
95 +
	feed, err := parser.Parse(resp.Body)
96 +
	if err != nil {
97 +
		return nil, fmt.Errorf("feed parse failed: %w", err)
98 +
	}
99 +
	result.Title = strings.TrimSpace(feed.Title)
100 +
	result.SiteURL = firstNonEmpty(feed.Link, firstFeedAltLink(feed))
101 +
	for _, item := range feed.Items {
102 +
		link := strings.TrimSpace(item.Link)
103 +
		if link == "" {
104 +
			continue
105 +
		}
106 +
		title := strings.TrimSpace(item.Title)
107 +
		if title == "" {
108 +
			title = deriveTitleFromHTML(firstNonEmpty(item.Description, item.Content))
109 +
		}
110 +
		author := ""
111 +
		if item.Author != nil {
112 +
			author = strings.TrimSpace(item.Author.Name)
113 +
		}
114 +
		guid := strings.TrimSpace(item.GUID)
115 +
		if guid == "" {
116 +
			guid = link
117 +
		}
118 +
		published := int64(0)
119 +
		switch {
120 +
		case item.PublishedParsed != nil:
121 +
			published = item.PublishedParsed.Unix()
122 +
		case item.UpdatedParsed != nil:
123 +
			published = item.UpdatedParsed.Unix()
124 +
		}
125 +
		result.Entries = append(result.Entries, ParsedEntry{
126 +
			GUID:        guid,
127 +
			Title:       title,
128 +
			Link:        link,
129 +
			Author:      author,
130 +
			PublishedAt: published,
131 +
		})
132 +
	}
133 +
	return result, nil
134 +
}
135 +
136 +
func deriveTitleFromHTML(src string) string {
137 +
	txt := strings.Join(strings.Fields(htmlToText(src)), " ")
138 +
	if txt == "" {
139 +
		return ""
140 +
	}
141 +
	const maxChars = 80
142 +
	if utf8.RuneCountInString(txt) <= maxChars {
143 +
		return txt
144 +
	}
145 +
	runes := []rune(txt)
146 +
	return strings.TrimSpace(string(runes[:maxChars])) + "…"
147 +
}
148 +
149 +
func htmlToText(src string) string {
150 +
	if strings.TrimSpace(src) == "" {
151 +
		return ""
152 +
	}
153 +
	node, err := html.Parse(strings.NewReader(src))
154 +
	if err != nil {
155 +
		return src
156 +
	}
157 +
	var b strings.Builder
158 +
	var walk func(*html.Node)
159 +
	walk = func(n *html.Node) {
160 +
		if n.Type == html.TextNode {
161 +
			b.WriteString(n.Data)
162 +
			b.WriteByte(' ')
163 +
		}
164 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
165 +
			walk(c)
166 +
		}
167 +
	}
168 +
	walk(node)
169 +
	return html.UnescapeString(b.String())
170 +
}
171 +
172 +
func previewURLs(ctx context.Context, urls []string, log *slog.Logger) []FeedPreviewItem {
173 +
	var wg sync.WaitGroup
174 +
	var mu sync.Mutex
175 +
	items := []FeedPreviewItem{}
176 +
	for _, raw := range urls {
177 +
		feedURL := strings.TrimSpace(raw)
178 +
		if feedURL == "" {
179 +
			continue
180 +
		}
181 +
		wg.Add(1)
182 +
		go func() {
183 +
			defer wg.Done()
184 +
			res, err := fetchFeed(ctx, feedURL, "", "")
185 +
			if err != nil {
186 +
				log.Warn("preview fetch failed", "url", feedURL, "err", err)
187 +
				return
188 +
			}
189 +
			feedTitle := res.Title
190 +
			local := make([]FeedPreviewItem, 0, len(res.Entries))
191 +
			for _, entry := range res.Entries {
192 +
				author := feedTitle
193 +
				if entry.Author != "" && feedTitle != "" {
194 +
					author = feedTitle + " - " + entry.Author
195 +
				} else if entry.Author != "" {
196 +
					author = entry.Author
197 +
				}
198 +
				local = append(local, FeedPreviewItem{Title: entry.Title, Link: entry.Link, Author: author, Published: entry.PublishedAt})
199 +
			}
200 +
			mu.Lock()
201 +
			items = append(items, local...)
202 +
			mu.Unlock()
203 +
		}()
204 +
	}
205 +
	wg.Wait()
206 +
	slices.SortFunc(items, func(a, b FeedPreviewItem) int {
207 +
		switch {
208 +
		case a.Published > b.Published:
209 +
			return -1
210 +
		case a.Published < b.Published:
211 +
			return 1
212 +
		default:
213 +
			return 0
214 +
		}
215 +
	})
216 +
	return items
217 +
}
218 +
219 +
func discoverFavicon(ctx context.Context, siteURL string) string {
220 +
	parsed, err := url.Parse(siteURL)
221 +
	if err != nil {
222 +
		return ""
223 +
	}
224 +
	client := buildHTTPClient()
225 +
	req, err := newRequest(ctx, http.MethodGet, siteURL)
226 +
	if err != nil {
227 +
		return ""
228 +
	}
229 +
	resp, err := client.Do(req)
230 +
	if err == nil {
231 +
		defer resp.Body.Close()
232 +
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
233 +
		if href := findLinkHref(string(body), func(rel, typ string) bool {
234 +
			rel = strings.ToLower(rel)
235 +
			return strings.Contains(rel, "icon")
236 +
		}); href != "" {
237 +
			if resolved, err := parsed.Parse(href); err == nil {
238 +
				return resolved.String()
239 +
			}
240 +
		}
241 +
	}
242 +
	if fallback, err := parsed.Parse("/favicon.ico"); err == nil {
243 +
		return fallback.String()
244 +
	}
245 +
	return ""
246 +
}
247 +
248 +
func discoverFeeds(ctx context.Context, baseURL string) ([]string, error) {
249 +
	parsed, err := url.Parse(baseURL)
250 +
	if err != nil {
251 +
		return nil, fmt.Errorf("invalid URL: %w", err)
252 +
	}
253 +
	client := buildHTTPClient()
254 +
	req, err := newRequest(ctx, http.MethodGet, baseURL)
255 +
	if err != nil {
256 +
		return nil, fmt.Errorf("invalid URL: %w", err)
257 +
	}
258 +
	feeds := []string{}
259 +
	resp, err := client.Do(req)
260 +
	if err == nil {
261 +
		defer resp.Body.Close()
262 +
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
263 +
		links := findAlternateFeedLinks(string(body))
264 +
		for _, href := range links {
265 +
			resolved := href
266 +
			if u, err := parsed.Parse(href); err == nil {
267 +
				resolved = u.String()
268 +
			}
269 +
			if !slices.Contains(feeds, resolved) {
270 +
				feeds = append(feeds, resolved)
271 +
			}
272 +
		}
273 +
	}
274 +
	if len(feeds) == 0 {
275 +
		paths := []string{"/feed", "/feed.xml", "/rss", "/rss.xml", "/atom.xml", "/index.xml", "/feed/rss", "/blog/feed", "/blog/rss"}
276 +
		for _, path := range paths {
277 +
			probe, err := parsed.Parse(path)
278 +
			if err != nil {
279 +
				continue
280 +
			}
281 +
			req, err := newRequest(ctx, http.MethodHead, probe.String())
282 +
			if err != nil {
283 +
				continue
284 +
			}
285 +
			resp, err := client.Do(req)
286 +
			if err != nil {
287 +
				continue
288 +
			}
289 +
			_ = resp.Body.Close()
290 +
			ct := strings.ToLower(resp.Header.Get("Content-Type"))
291 +
			if resp.StatusCode >= 200 && resp.StatusCode < 300 && (strings.Contains(ct, "xml") || strings.Contains(ct, "rss") || strings.Contains(ct, "atom")) {
292 +
				feeds = append(feeds, probe.String())
293 +
			}
294 +
		}
295 +
	}
296 +
	if len(feeds) == 0 {
297 +
		return nil, errors.New("no feeds found at this URL")
298 +
	}
299 +
	return feeds, nil
300 +
}
301 +
302 +
func findAlternateFeedLinks(doc string) []string {
303 +
	node, err := html.Parse(strings.NewReader(doc))
304 +
	if err != nil {
305 +
		return nil
306 +
	}
307 +
	links := []string{}
308 +
	var walk func(*html.Node)
309 +
	walk = func(n *html.Node) {
310 +
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") {
311 +
			attrs := attrsMap(n)
312 +
			rel := strings.ToLower(attrs["rel"])
313 +
			typ := strings.ToLower(attrs["type"])
314 +
			href := attrs["href"]
315 +
			if strings.Contains(rel, "alternate") && href != "" && (strings.Contains(typ, "rss") || strings.Contains(typ, "atom") || strings.Contains(typ, "xml")) {
316 +
				links = append(links, href)
317 +
			}
318 +
		}
319 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
320 +
			walk(c)
321 +
		}
322 +
	}
323 +
	walk(node)
324 +
	return links
325 +
}
326 +
327 +
func findLinkHref(doc string, match func(rel, typ string) bool) string {
328 +
	node, err := html.Parse(strings.NewReader(doc))
329 +
	if err != nil {
330 +
		return ""
331 +
	}
332 +
	var found string
333 +
	var walk func(*html.Node)
334 +
	walk = func(n *html.Node) {
335 +
		if found != "" {
336 +
			return
337 +
		}
338 +
		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "link") {
339 +
			attrs := attrsMap(n)
340 +
			if match(attrs["rel"], attrs["type"]) {
341 +
				found = attrs["href"]
342 +
				return
343 +
			}
344 +
		}
345 +
		for c := n.FirstChild; c != nil; c = c.NextSibling {
346 +
			walk(c)
347 +
		}
348 +
	}
349 +
	walk(node)
350 +
	return found
351 +
}
352 +
353 +
func attrsMap(n *html.Node) map[string]string {
354 +
	out := make(map[string]string, len(n.Attr))
355 +
	for _, a := range n.Attr {
356 +
		out[strings.ToLower(a.Key)] = a.Val
357 +
	}
358 +
	return out
359 +
}
360 +
361 +
func firstFeedAltLink(feed *gofeed.Feed) string {
362 +
	for _, link := range feed.Links {
363 +
		if strings.TrimSpace(link) != "" {
364 +
			return link
365 +
		}
366 +
	}
367 +
	return ""
368 +
}
369 +
370 +
func firstNonEmpty(values ...string) string {
371 +
	for _, v := range values {
372 +
		if strings.TrimSpace(v) != "" {
373 +
			return strings.TrimSpace(v)
374 +
		}
375 +
	}
376 +
	return ""
377 +
}
apps/feeds-go/go.mod (added) +43 −0
1 +
module github.com/stevedylandev/andromeda/apps/feeds-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/mmcdole/gofeed v1.3.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/sqlite v0.0.0
11 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
12 +
	golang.org/x/net v0.41.0
13 +
)
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/sqlite => ../../crates-go/sqlite
20 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
21 +
)
22 +
23 +
require (
24 +
	github.com/PuerkitoBio/goquery v1.8.0 // indirect
25 +
	github.com/andybalholm/cascadia v1.3.1 // indirect
26 +
	github.com/dustin/go-humanize v1.0.1 // indirect
27 +
	github.com/google/uuid v1.6.0 // indirect
28 +
	github.com/json-iterator/go v1.1.12 // indirect
29 +
	github.com/mattn/go-isatty v0.0.20 // indirect
30 +
	github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
31 +
	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
32 +
	github.com/modern-go/reflect2 v1.0.2 // indirect
33 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
34 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
35 +
	golang.org/x/crypto v0.39.0 // indirect
36 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
37 +
	golang.org/x/sys v0.33.0 // indirect
38 +
	golang.org/x/text v0.26.0 // indirect
39 +
	modernc.org/libc v1.65.7 // indirect
40 +
	modernc.org/mathutil v1.7.1 // indirect
41 +
	modernc.org/memory v1.11.0 // indirect
42 +
	modernc.org/sqlite v1.37.1 // indirect
43 +
)
apps/feeds-go/go.sum (added) +86 −0
1 +
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
2 +
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
3 +
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
4 +
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
5 +
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 +
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7 +
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
9 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
10 +
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
16 +
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
20 +
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
21 +
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
22 +
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
23 +
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
24 +
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
25 +
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
26 +
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
27 +
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
28 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
29 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
30 +
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
31 +
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
32 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
33 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
34 +
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
35 +
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
36 +
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
37 +
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
38 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
39 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
40 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
41 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
42 +
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
43 +
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
44 +
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
45 +
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
46 +
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
47 +
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
48 +
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
49 +
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
50 +
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
51 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
52 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
53 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
54 +
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
55 +
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
56 +
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
57 +
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
58 +
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
59 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
60 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
61 +
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
62 +
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
63 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
64 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
65 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
66 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
67 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
68 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
69 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
70 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
71 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
72 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
73 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
74 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
75 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
76 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
77 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
78 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
79 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
80 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
81 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
82 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
83 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
84 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
85 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
86 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/feeds-go/handlers_admin.go (added) +187 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
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) loginGetHandler(w http.ResponseWriter, r *http.Request) {
13 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
14 +
}
15 +
16 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
17 +
	if a.AdminPassword == "" {
18 +
		web.RedirectWithError(w, r, "/admin/login", "No admin password configured")
19 +
		return
20 +
	}
21 +
	if err := r.ParseForm(); err != nil {
22 +
		web.RedirectWithError(w, r, "/admin/login", "Bad request")
23 +
		return
24 +
	}
25 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) {
26 +
		web.RedirectWithError(w, r, "/admin/login", "Invalid password")
27 +
		return
28 +
	}
29 +
	token, err := a.Sessions.Create()
30 +
	if err != nil {
31 +
		a.Log.Error("create session failed", "err", err)
32 +
		web.RedirectWithError(w, r, "/admin/login", "Session error")
33 +
		return
34 +
	}
35 +
	a.Sessions.PruneExpired()
36 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
37 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
38 +
}
39 +
40 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
41 +
	if cookie, err := r.Cookie(a.Sessions.CookieName); err == nil {
42 +
		a.Sessions.Delete(cookie.Value)
43 +
	}
44 +
	http.SetCookie(w, a.Sessions.ClearCookie())
45 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
46 +
}
47 +
48 +
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
49 +
	subs, _ := listSubscriptions(a.DB)
50 +
	cats, _ := listCategories(a.DB)
51 +
	catMap := map[int64]string{}
52 +
	for _, c := range cats {
53 +
		catMap[c.ID] = c.Name
54 +
	}
55 +
	rows := []adminSubRow{}
56 +
	for _, s := range subs {
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 +
	}
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 +
}
61 +
62 +
func (a *App) discoverFeedsHandler(w http.ResponseWriter, r *http.Request) {
63 +
	if err := r.ParseForm(); err != nil {
64 +
		web.WriteError(w, http.StatusBadRequest, "bad request")
65 +
		return
66 +
	}
67 +
	feeds, err := discoverFeeds(r.Context(), r.FormValue("base_url"))
68 +
	if err != nil {
69 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
70 +
		return
71 +
	}
72 +
	web.WriteJSON(w, http.StatusOK, feeds)
73 +
}
74 +
75 +
func (a *App) addFeedHandler(w http.ResponseWriter, r *http.Request) {
76 +
	if err := r.ParseForm(); err != nil {
77 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
78 +
		return
79 +
	}
80 +
	body := createSubscriptionBody{FeedURL: r.FormValue("feed_url"), CategoryName: r.FormValue("category_name")}
81 +
	if _, err := a.createSubscription(r.Context(), body, true); err != nil {
82 +
		if isAlreadySubscribedError(err) {
83 +
			web.RedirectWithError(w, r, "/admin", "Already subscribed")
84 +
			return
85 +
		}
86 +
		web.RedirectWithError(w, r, "/admin", "Failed to add feed")
87 +
		return
88 +
	}
89 +
	web.RedirectWithSuccess(w, r, "/admin", "Feed added and will be fetched in the background")
90 +
}
91 +
92 +
func (a *App) deleteFeedHandler(w http.ResponseWriter, r *http.Request) {
93 +
	id, ok := web.PathInt64(r, "id")
94 +
	if !ok {
95 +
		web.RedirectWithError(w, r, "/admin", "Invalid feed ID")
96 +
		return
97 +
	}
98 +
	deleted, err := deleteSubscription(a.DB, id)
99 +
	if err != nil || !deleted {
100 +
		web.RedirectWithError(w, r, "/admin", "Failed to remove")
101 +
		return
102 +
	}
103 +
	web.RedirectWithSuccess(w, r, "/admin", "Feed removed")
104 +
}
105 +
106 +
func (a *App) updateSubCategoryHandler(w http.ResponseWriter, r *http.Request) {
107 +
	id, ok := web.PathInt64(r, "id")
108 +
	if !ok {
109 +
		web.RedirectWithError(w, r, "/admin", "Invalid feed ID")
110 +
		return
111 +
	}
112 +
	if err := r.ParseForm(); err != nil {
113 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
114 +
		return
115 +
	}
116 +
	categoryID, err := a.resolveCategory(nil, r.FormValue("category_name"))
117 +
	if err != nil {
118 +
		web.RedirectWithError(w, r, "/admin", "Failed to update category")
119 +
		return
120 +
	}
121 +
	if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil {
122 +
		web.RedirectWithError(w, r, "/admin", "Failed to update category")
123 +
		return
124 +
	}
125 +
	web.RedirectWithSuccess(w, r, "/admin", "Category updated")
126 +
}
127 +
128 +
func (a *App) addCategoryHandler(w http.ResponseWriter, r *http.Request) {
129 +
	if err := r.ParseForm(); err != nil {
130 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
131 +
		return
132 +
	}
133 +
	name := strings.TrimSpace(r.FormValue("name"))
134 +
	if name == "" {
135 +
		web.RedirectWithError(w, r, "/admin", "Name required")
136 +
		return
137 +
	}
138 +
	if _, err := getOrCreateCategory(a.DB, name); err != nil {
139 +
		web.RedirectWithError(w, r, "/admin", "Failed to add category")
140 +
		return
141 +
	}
142 +
	web.RedirectWithSuccess(w, r, "/admin", "Category added")
143 +
}
144 +
145 +
func (a *App) deleteCategoryHandler(w http.ResponseWriter, r *http.Request) {
146 +
	id, ok := web.PathInt64(r, "id")
147 +
	if !ok {
148 +
		web.RedirectWithError(w, r, "/admin", "Invalid category ID")
149 +
		return
150 +
	}
151 +
	deleted, err := deleteCategory(a.DB, id)
152 +
	if err != nil {
153 +
		web.RedirectWithError(w, r, "/admin", "Failed to remove category")
154 +
		return
155 +
	}
156 +
	if !deleted {
157 +
		web.RedirectWithError(w, r, "/admin", "Category not found")
158 +
		return
159 +
	}
160 +
	web.RedirectWithSuccess(w, r, "/admin", "Category removed")
161 +
}
162 +
163 +
func (a *App) importOPMLHandler(w http.ResponseWriter, r *http.Request) {
164 +
	summary, err := a.readAndImportOPML(r)
165 +
	if err != nil {
166 +
		web.RedirectWithError(w, r, "/admin", "No file uploaded")
167 +
		return
168 +
	}
169 +
	web.RedirectWithSuccess(w, r, "/admin", fmt.Sprintf("Imported %d, skipped %d", summary.Imported, summary.Skipped))
170 +
}
171 +
172 +
func (a *App) updateSettingsFormHandler(w http.ResponseWriter, r *http.Request) {
173 +
	if err := r.ParseForm(); err != nil {
174 +
		web.RedirectWithError(w, r, "/admin", "Bad request")
175 +
		return
176 +
	}
177 +
	mins, ok := formPollMinutes(r)
178 +
	if !ok {
179 +
		web.RedirectWithError(w, r, "/admin", "Interval must be 1-1440")
180 +
		return
181 +
	}
182 +
	if err := setSetting(a.DB, "poll_interval_minutes", fmt.Sprintf("%d", mins)); err != nil {
183 +
		web.RedirectWithError(w, r, "/admin", "Failed to save settings")
184 +
		return
185 +
	}
186 +
	web.RedirectWithSuccess(w, r, "/admin", "Settings saved")
187 +
}
apps/feeds-go/handlers_api.go (added) +190 −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) listItemsAPI(w http.ResponseWriter, r *http.Request) {
10 +
	items, err := listItems(a.DB, itemFilterFromRequest(r))
11 +
	if err != nil {
12 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
13 +
		return
14 +
	}
15 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
16 +
}
17 +
18 +
func (a *App) markItemReadAPI(isRead bool) http.HandlerFunc {
19 +
	return func(w http.ResponseWriter, r *http.Request) {
20 +
		id, ok := web.PathInt64(r, "id")
21 +
		if !ok {
22 +
			web.WriteError(w, http.StatusBadRequest, "invalid item id")
23 +
			return
24 +
		}
25 +
		updated, err := markItemRead(a.DB, id, isRead)
26 +
		if err != nil {
27 +
			web.WriteError(w, http.StatusInternalServerError, err.Error())
28 +
			return
29 +
		}
30 +
		if !updated {
31 +
			web.WriteError(w, http.StatusNotFound, "item not found")
32 +
			return
33 +
		}
34 +
		web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "is_read": isRead})
35 +
	}
36 +
}
37 +
38 +
func (a *App) listSubscriptionsAPI(w http.ResponseWriter, r *http.Request) {
39 +
	subs, err := listSubscriptions(a.DB)
40 +
	if err != nil {
41 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
42 +
		return
43 +
	}
44 +
	views := make([]subscriptionView, 0, len(subs))
45 +
	for _, sub := range subs {
46 +
		views = append(views, toSubscriptionView(sub))
47 +
	}
48 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"subscriptions": views})
49 +
}
50 +
51 +
func (a *App) createSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
52 +
	var body createSubscriptionBody
53 +
	if !web.DecodeJSON(w, r, &body) {
54 +
		return
55 +
	}
56 +
	sub, err := a.createSubscription(r.Context(), body, false)
57 +
	if err != nil {
58 +
		status := http.StatusBadRequest
59 +
		if isAlreadySubscribedError(err) {
60 +
			status = http.StatusConflict
61 +
		}
62 +
		web.WriteError(w, status, err.Error())
63 +
		return
64 +
	}
65 +
	web.WriteJSON(w, http.StatusCreated, map[string]any{"subscription": toSubscriptionView(*sub)})
66 +
}
67 +
68 +
func (a *App) updateSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
69 +
	id, ok := web.PathInt64(r, "id")
70 +
	if !ok {
71 +
		web.WriteError(w, http.StatusBadRequest, "invalid subscription id")
72 +
		return
73 +
	}
74 +
	var body updateSubscriptionBody
75 +
	if !web.DecodeJSON(w, r, &body) {
76 +
		return
77 +
	}
78 +
	categoryID, err := a.resolveSubscriptionCategory(body)
79 +
	if err != nil {
80 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
81 +
		return
82 +
	}
83 +
	if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil {
84 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
85 +
		return
86 +
	}
87 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
88 +
}
89 +
90 +
func (a *App) deleteSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
91 +
	id, ok := web.PathInt64(r, "id")
92 +
	if !ok {
93 +
		web.WriteError(w, http.StatusBadRequest, "invalid subscription id")
94 +
		return
95 +
	}
96 +
	deleted, err := deleteSubscription(a.DB, id)
97 +
	if err != nil {
98 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
99 +
		return
100 +
	}
101 +
	if !deleted {
102 +
		web.WriteError(w, http.StatusNotFound, "subscription not found")
103 +
		return
104 +
	}
105 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
106 +
}
107 +
108 +
func (a *App) listCategoriesAPI(w http.ResponseWriter, r *http.Request) {
109 +
	cats, err := listCategories(a.DB)
110 +
	if err != nil {
111 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
112 +
		return
113 +
	}
114 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"categories": cats})
115 +
}
116 +
117 +
func (a *App) createCategoryAPI(w http.ResponseWriter, r *http.Request) {
118 +
	var body createCategoryBody
119 +
	if !web.DecodeJSON(w, r, &body) {
120 +
		return
121 +
	}
122 +
	cat, err := getOrCreateCategory(a.DB, body.Name)
123 +
	if err != nil {
124 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
125 +
		return
126 +
	}
127 +
	web.WriteJSON(w, http.StatusCreated, map[string]any{"category": cat})
128 +
}
129 +
130 +
func (a *App) deleteCategoryAPI(w http.ResponseWriter, r *http.Request) {
131 +
	id, ok := web.PathInt64(r, "id")
132 +
	if !ok {
133 +
		web.WriteError(w, http.StatusBadRequest, "invalid category id")
134 +
		return
135 +
	}
136 +
	deleted, err := deleteCategory(a.DB, id)
137 +
	if err != nil {
138 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
139 +
		return
140 +
	}
141 +
	if !deleted {
142 +
		web.WriteError(w, http.StatusNotFound, "category not found")
143 +
		return
144 +
	}
145 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
146 +
}
147 +
148 +
func (a *App) importOPMLAPI(w http.ResponseWriter, r *http.Request) {
149 +
	summary, err := a.readAndImportOPML(r)
150 +
	if err != nil {
151 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
152 +
		return
153 +
	}
154 +
	web.WriteJSON(w, http.StatusOK, summary)
155 +
}
156 +
157 +
func (a *App) getSettingsAPI(w http.ResponseWriter, r *http.Request) {
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 != ""})
159 +
}
160 +
161 +
func (a *App) updateSettingsAPI(w http.ResponseWriter, r *http.Request) {
162 +
	var body updateSettingsBody
163 +
	if !web.DecodeJSON(w, r, &body) {
164 +
		return
165 +
	}
166 +
	if body.PollIntervalMinutes != nil {
167 +
		if !validPollMinutes(*body.PollIntervalMinutes) {
168 +
			web.WriteError(w, http.StatusBadRequest, "poll_interval_minutes must be between 1 and 1440")
169 +
			return
170 +
		}
171 +
		if err := setSetting(a.DB, "poll_interval_minutes", itoa(*body.PollIntervalMinutes)); err != nil {
172 +
			web.WriteError(w, http.StatusInternalServerError, err.Error())
173 +
			return
174 +
		}
175 +
	}
176 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
177 +
}
178 +
179 +
func (a *App) discoverAPI(w http.ResponseWriter, r *http.Request) {
180 +
	var body discoverBody
181 +
	if !web.DecodeJSON(w, r, &body) {
182 +
		return
183 +
	}
184 +
	feeds, err := discoverFeeds(r.Context(), body.BaseURL)
185 +
	if err != nil {
186 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
187 +
		return
188 +
	}
189 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"feeds": feeds})
190 +
}
apps/feeds-go/handlers_public.go (added) +198 −0
1 +
package main
2 +
3 +
import (
4 +
	"encoding/xml"
5 +
	"fmt"
6 +
	"net/http"
7 +
	"strings"
8 +
	"time"
9 +
10 +
	"github.com/stevedylandev/andromeda/crates-go/web"
11 +
)
12 +
13 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
14 +
	query := r.URL.Query().Get("url")
15 +
	if query == "" {
16 +
		query = r.URL.Query().Get("urls")
17 +
	}
18 +
	data := indexPageData{BaseURL: a.BaseURL}
19 +
	if query != "" {
20 +
		urls := splitAndTrim(query)
21 +
		data.FeedURLs = urls
22 +
		if len(urls) == 0 {
23 +
			data.Error = "No URLs provided"
24 +
			web.Render(a.Templates, w, "index.html", data, a.Log)
25 +
			return
26 +
		}
27 +
		for _, item := range previewURLs(r.Context(), urls, a.Log) {
28 +
			data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
29 +
		}
30 +
		web.Render(a.Templates, w, "index.html", data, a.Log)
31 +
		return
32 +
	}
33 +
34 +
	items, err := listItems(a.DB, ListItemsFilter{Limit: 100})
35 +
	if err != nil {
36 +
		a.Log.Error("index query failed", "err", err)
37 +
		data.Error = "Error loading feeds. Please try again later."
38 +
		web.Render(a.Templates, w, "index.html", data, a.Log)
39 +
		return
40 +
	}
41 +
	for _, item := range items {
42 +
		author := item.FeedTitle
43 +
		if item.Author != nil && strings.TrimSpace(*item.Author) != "" {
44 +
			author = item.FeedTitle + " - " + *item.Author
45 +
		}
46 +
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: author, FormattedDate: formatDate(item.PublishedAt)})
47 +
	}
48 +
	web.Render(a.Templates, w, "index.html", data, a.Log)
49 +
}
50 +
51 +
func (a *App) feedsExportHandler(w http.ResponseWriter, r *http.Request) {
52 +
	subs, err := listSubscriptions(a.DB)
53 +
	if err != nil {
54 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
55 +
		return
56 +
	}
57 +
58 +
	switch r.URL.Query().Get("format") {
59 +
	case "", "json":
60 +
		rows := make([]map[string]any, 0, len(subs))
61 +
		for _, s := range subs {
62 +
			rows = append(rows, map[string]any{
63 +
				"id":      fmt.Sprintf("feed/%d", s.ID),
64 +
				"title":   s.Title,
65 +
				"url":     s.FeedURL,
66 +
				"htmlUrl": nullStringValue(s.SiteURL),
67 +
			})
68 +
		}
69 +
		web.WriteJSON(w, http.StatusOK, map[string]any{"subscriptions": rows})
70 +
	case "opml":
71 +
		a.writeOPMLExport(w, subs)
72 +
	default:
73 +
		web.WriteError(w, http.StatusBadRequest, "Invalid format. Use ?format=json or ?format=opml")
74 +
	}
75 +
}
76 +
77 +
func (a *App) atomFeedHandler(w http.ResponseWriter, r *http.Request) {
78 +
	items, err := listItems(a.DB, ListItemsFilter{Limit: 100})
79 +
	if err != nil {
80 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
81 +
		return
82 +
	}
83 +
	type atomLink struct {
84 +
		Href string `xml:"href,attr"`
85 +
		Rel  string `xml:"rel,attr,omitempty"`
86 +
		Type string `xml:"type,attr,omitempty"`
87 +
	}
88 +
	type atomAuthor struct {
89 +
		Name string `xml:"name"`
90 +
	}
91 +
	type atomSource struct {
92 +
		Title string `xml:"title"`
93 +
	}
94 +
	type atomEntry struct {
95 +
		Title     string     `xml:"title"`
96 +
		Link      atomLink   `xml:"link"`
97 +
		ID        string     `xml:"id"`
98 +
		Updated   string     `xml:"updated"`
99 +
		Published string     `xml:"published"`
100 +
		Author    atomAuthor `xml:"author"`
101 +
		Source    atomSource `xml:"source"`
102 +
	}
103 +
	type atomFeed struct {
104 +
		XMLName xml.Name    `xml:"feed"`
105 +
		Xmlns   string      `xml:"xmlns,attr"`
106 +
		Title   string      `xml:"title"`
107 +
		Links   []atomLink  `xml:"link"`
108 +
		ID      string      `xml:"id"`
109 +
		Updated string      `xml:"updated"`
110 +
		Entries []atomEntry `xml:"entry"`
111 +
	}
112 +
113 +
	updated := time.Now().UTC().Format(time.RFC3339)
114 +
	if len(items) > 0 {
115 +
		updated = time.Unix(items[0].PublishedAt, 0).UTC().Format(time.RFC3339)
116 +
	}
117 +
	base := strings.TrimRight(a.BaseURL, "/")
118 +
	feed := atomFeed{
119 +
		Xmlns:   "http://www.w3.org/2005/Atom",
120 +
		Title:   "Feeds",
121 +
		ID:      base + "/feed.xml",
122 +
		Updated: updated,
123 +
		Links:   []atomLink{{Href: base + "/feed.xml", Rel: "self", Type: "application/atom+xml"}, {Href: base}},
124 +
	}
125 +
	for _, item := range items {
126 +
		author := item.FeedTitle
127 +
		if item.Author != nil && *item.Author != "" {
128 +
			author = *item.Author
129 +
		}
130 +
		entryID := item.GUID
131 +
		if strings.TrimSpace(entryID) == "" {
132 +
			entryID = item.Link
133 +
		}
134 +
		published := time.Unix(item.PublishedAt, 0).UTC().Format(time.RFC3339)
135 +
		feed.Entries = append(feed.Entries, atomEntry{Title: item.Title, Link: atomLink{Href: item.Link}, ID: entryID, Updated: published, Published: published, Author: atomAuthor{Name: author}, Source: atomSource{Title: item.FeedTitle}})
136 +
	}
137 +
	body, _ := xml.MarshalIndent(feed, "", "  ")
138 +
	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
139 +
	_, _ = w.Write([]byte(xml.Header))
140 +
	_, _ = w.Write(body)
141 +
}
142 +
143 +
func (a *App) writeOPMLExport(w http.ResponseWriter, subs []Subscription) {
144 +
	cats, _ := listCategories(a.DB)
145 +
	catNames := map[int64]string{}
146 +
	for _, c := range cats {
147 +
		catNames[c.ID] = c.Name
148 +
	}
149 +
	grouped := map[string][]Subscription{}
150 +
	for _, s := range subs {
151 +
		key := ""
152 +
		if s.CategoryID.Valid {
153 +
			key = catNames[s.CategoryID.Int64]
154 +
		}
155 +
		grouped[key] = append(grouped[key], s)
156 +
	}
157 +
	type outline struct {
158 +
		XMLName xml.Name  `xml:"outline"`
159 +
		Text    string    `xml:"text,attr,omitempty"`
160 +
		Title   string    `xml:"title,attr,omitempty"`
161 +
		Type    string    `xml:"type,attr,omitempty"`
162 +
		XMLURL  string    `xml:"xmlUrl,attr,omitempty"`
163 +
		HTMLURL string    `xml:"htmlUrl,attr,omitempty"`
164 +
		Nodes   []outline `xml:"outline,omitempty"`
165 +
	}
166 +
	type opml struct {
167 +
		XMLName xml.Name `xml:"opml"`
168 +
		Version string   `xml:"version,attr"`
169 +
		Head    struct {
170 +
			Title       string `xml:"title"`
171 +
			DateCreated string `xml:"dateCreated"`
172 +
		} `xml:"head"`
173 +
		Body struct {
174 +
			Nodes []outline `xml:"outline"`
175 +
		} `xml:"body"`
176 +
	}
177 +
	doc := opml{Version: "2.0"}
178 +
	doc.Head.Title = "Feeds"
179 +
	doc.Head.DateCreated = time.Now().Format(time.RFC1123Z)
180 +
	for category, rows := range grouped {
181 +
		if category == "" {
182 +
			for _, s := range rows {
183 +
				doc.Body.Nodes = append(doc.Body.Nodes, outline{Text: s.Title, Title: s.Title, Type: "rss", XMLURL: s.FeedURL, HTMLURL: nullStringValue(s.SiteURL)})
184 +
			}
185 +
			continue
186 +
		}
187 +
		group := outline{Text: category, Title: category}
188 +
		for _, s := range rows {
189 +
			group.Nodes = append(group.Nodes, outline{Text: s.Title, Title: s.Title, Type: "rss", XMLURL: s.FeedURL, HTMLURL: nullStringValue(s.SiteURL)})
190 +
		}
191 +
		doc.Body.Nodes = append(doc.Body.Nodes, group)
192 +
	}
193 +
	body, _ := xml.MarshalIndent(doc, "", "  ")
194 +
	w.Header().Set("Content-Type", "application/xml")
195 +
	w.Header().Set("Content-Disposition", `attachment; filename="feeds.opml"`)
196 +
	_, _ = w.Write([]byte(xml.Header))
197 +
	_, _ = w.Write(body)
198 +
}
apps/feeds-go/main.go (added) +62 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"html/template"
6 +
	"log"
7 +
	"log/slog"
8 +
	"net/http"
9 +
	"os"
10 +
11 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 +
	"github.com/stevedylandev/andromeda/crates-go/config"
13 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
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("FEEDS_DB_PATH", "feeds.sqlite")
21 +
	db, err := sqlite.Open(dbPath, feedsSchema)
22 +
	if err != nil {
23 +
		log.Fatal(err)
24 +
	}
25 +
	defer db.Close()
26 +
27 +
	defaultPoll := config.GetenvInt("DEFAULT_POLL_MINUTES", 30)
28 +
	itemCap := config.GetenvInt("ITEM_CAP_PER_FEED", 200)
29 +
	if err := seedSettings(db, defaultPoll); err != nil {
30 +
		log.Fatal(err)
31 +
	}
32 +
33 +
	sessions := &auth.Store{DB: db, CookieName: "feeds_session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
34 +
	if err := sessions.EnsureSchema(); err != nil {
35 +
		log.Fatal(err)
36 +
	}
37 +
	sessions.PruneExpired()
38 +
39 +
	tmpl := template.Must(template.New("").Funcs(template.FuncMap{"safeURL": func(s string) string { return s }}).ParseFS(appFS, "templates/*.html"))
40 +
	app := &App{
41 +
		DB:                 db,
42 +
		Log:                logger,
43 +
		Templates:          tmpl,
44 +
		Sessions:           sessions,
45 +
		AdminPassword:      os.Getenv("ADMIN_PASSWORD"),
46 +
		APIKey:             os.Getenv("API_KEY"),
47 +
		CookieSecure:       sessions.CookieSecure,
48 +
		BaseURL:            config.Getenv("BASE_URL", "http://localhost:3000"),
49 +
		DefaultPollMinutes: defaultPoll,
50 +
		ItemCap:            itemCap,
51 +
	}
52 +
	if app.APIKey == "" {
53 +
		logger.Warn("API_KEY is not set; API requires session cookie only")
54 +
	}
55 +
	go app.poller(context.Background())
56 +
57 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
58 +
	logger.Info("feeds-go server running", "addr", addr)
59 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
60 +
		log.Fatal(err)
61 +
	}
62 +
}
apps/feeds-go/middleware.go (added) +16 −0
1 +
package main
2 +
3 +
import "net/http"
4 +
5 +
func (a *App) withCORS(next http.HandlerFunc) http.HandlerFunc {
6 +
	return func(w http.ResponseWriter, r *http.Request) {
7 +
		w.Header().Set("Access-Control-Allow-Origin", "*")
8 +
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
9 +
		w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
10 +
		if r.Method == http.MethodOptions {
11 +
			w.WriteHeader(http.StatusNoContent)
12 +
			return
13 +
		}
14 +
		next(w, r)
15 +
	}
16 +
}
apps/feeds-go/opml.go (added) +50 −0
1 +
package main
2 +
3 +
import (
4 +
	"encoding/xml"
5 +
	"strings"
6 +
)
7 +
8 +
type OPMLEntry struct {
9 +
	XMLURL   string
10 +
	Title    string
11 +
	HTMLURL  string
12 +
	Category string
13 +
}
14 +
15 +
func parseOPML(content string) []OPMLEntry {
16 +
	dec := xml.NewDecoder(strings.NewReader(content))
17 +
	type outline struct {
18 +
		Title   string    `xml:"title,attr"`
19 +
		Text    string    `xml:"text,attr"`
20 +
		XMLURL  string    `xml:"xmlUrl,attr"`
21 +
		HTMLURL string    `xml:"htmlUrl,attr"`
22 +
		Nodes   []outline `xml:"outline"`
23 +
	}
24 +
	type opml struct {
25 +
		Body struct {
26 +
			Nodes []outline `xml:"outline"`
27 +
		} `xml:"body"`
28 +
	}
29 +
	var doc opml
30 +
	if err := dec.Decode(&doc); err != nil {
31 +
		return nil
32 +
	}
33 +
	var out []OPMLEntry
34 +
	var walk func(nodes []outline, category string)
35 +
	walk = func(nodes []outline, category string) {
36 +
		for _, node := range nodes {
37 +
			title := firstNonEmpty(node.Title, node.Text)
38 +
			if strings.TrimSpace(node.XMLURL) != "" {
39 +
				out = append(out, OPMLEntry{XMLURL: strings.TrimSpace(node.XMLURL), Title: title, HTMLURL: strings.TrimSpace(node.HTMLURL), Category: strings.TrimSpace(category)})
40 +
				if len(node.Nodes) > 0 {
41 +
					walk(node.Nodes, title)
42 +
				}
43 +
				continue
44 +
			}
45 +
			walk(node.Nodes, title)
46 +
		}
47 +
	}
48 +
	walk(doc.Body.Nodes, "")
49 +
	return out
50 +
}
apps/feeds-go/routes.go (added) +56 −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 +
	mux.HandleFunc("GET /", a.indexHandler)
15 +
	mux.HandleFunc("GET /feeds", a.feedsExportHandler)
16 +
	mux.HandleFunc("GET /feed.xml", a.atomFeedHandler)
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 +
	}
26 +
27 +
	mux.HandleFunc("GET /admin/login", a.loginGetHandler)
28 +
	mux.HandleFunc("POST /admin/login", a.loginPostHandler)
29 +
	mux.HandleFunc("GET /admin/logout", a.logoutHandler)
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))
39 +
40 +
	mux.HandleFunc("GET /api/items", a.withCORS(a.listItemsAPI))
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))))
43 +
	mux.HandleFunc("GET /api/subscriptions", a.withCORS(a.listSubscriptionsAPI))
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)))
47 +
	mux.HandleFunc("GET /api/categories", a.withCORS(a.listCategoriesAPI))
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)))
51 +
	mux.HandleFunc("GET /api/settings", a.withCORS(a.getSettingsAPI))
52 +
	mux.HandleFunc("PUT /api/settings", a.withCORS(requireAPIAuth(a.updateSettingsAPI)))
53 +
	mux.HandleFunc("POST /api/discover", a.withCORS(requireAPIAuth(a.discoverAPI)))
54 +
55 +
	return mux
56 +
}
apps/feeds-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/feeds-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/feeds-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/feeds-go/subscriptions.go (added) +218 −0
1 +
package main
2 +
3 +
import (
4 +
	"context"
5 +
	"database/sql"
6 +
	"fmt"
7 +
	"io"
8 +
	"net/http"
9 +
	"strings"
10 +
	"time"
11 +
)
12 +
13 +
const errAlreadySubscribed = "already subscribed"
14 +
15 +
func (a *App) createSubscription(ctx context.Context, body createSubscriptionBody, background bool) (*Subscription, error) {
16 +
	feedURL := strings.TrimSpace(body.FeedURL)
17 +
	if feedURL == "" {
18 +
		return nil, fmt.Errorf("feed_url required")
19 +
	}
20 +
	existing, err := getSubscriptionByURL(a.DB, feedURL)
21 +
	if err != nil {
22 +
		return nil, err
23 +
	}
24 +
	if existing != nil {
25 +
		return nil, fmt.Errorf(errAlreadySubscribed)
26 +
	}
27 +
	categoryID, err := a.resolveCategory(body.CategoryID, body.CategoryName)
28 +
	if err != nil {
29 +
		return nil, err
30 +
	}
31 +
	title := firstNonEmpty(body.Title, feedURL)
32 +
	if background {
33 +
		return a.createSubscriptionInBackground(feedURL, title, body, categoryID)
34 +
	}
35 +
	return a.createSubscriptionImmediately(ctx, feedURL, title, body, categoryID)
36 +
}
37 +
38 +
func (a *App) createSubscriptionInBackground(feedURL, title string, body createSubscriptionBody, categoryID *int64) (*Subscription, error) {
39 +
	sub, err := insertSubscription(a.DB, feedURL, title, nil, categoryID)
40 +
	if err != nil {
41 +
		return nil, err
42 +
	}
43 +
	go func(subID int64, originalTitle string) {
44 +
		res, err := fetchFeed(context.Background(), feedURL, "", "")
45 +
		if err != nil {
46 +
			msg := err.Error()
47 +
			_ = updateSubscriptionMeta(a.DB, subID, nil, nil, &msg)
48 +
			return
49 +
		}
50 +
		resolvedTitle := firstNonEmpty(body.Title, res.Title, feedURL)
51 +
		if resolvedTitle != originalTitle {
52 +
			_ = updateSubscriptionTitle(a.DB, subID, resolvedTitle)
53 +
		}
54 +
		if res.SiteURL != "" {
55 +
			_ = updateSubscriptionSiteURL(a.DB, subID, &res.SiteURL)
56 +
			if fav := discoverFavicon(context.Background(), res.SiteURL); fav != "" {
57 +
				_ = updateSubscriptionFavicon(a.DB, subID, &fav)
58 +
			}
59 +
		}
60 +
		_ = a.seedSubscription(subID, res)
61 +
	}(sub.ID, sub.Title)
62 +
	return sub, nil
63 +
}
64 +
65 +
func (a *App) createSubscriptionImmediately(ctx context.Context, feedURL, title string, body createSubscriptionBody, categoryID *int64) (*Subscription, error) {
66 +
	res, err := fetchFeed(ctx, feedURL, "", "")
67 +
	if err != nil {
68 +
		return nil, fmt.Errorf("feed not reachable: %w", err)
69 +
	}
70 +
	resolvedTitle := firstNonEmpty(body.Title, res.Title, feedURL)
71 +
	siteURL := stringPtr(res.SiteURL)
72 +
	sub, err := insertSubscription(a.DB, feedURL, resolvedTitle, siteURL, categoryID)
73 +
	if err != nil {
74 +
		return nil, err
75 +
	}
76 +
	if res.SiteURL != "" {
77 +
		if fav := discoverFavicon(ctx, res.SiteURL); fav != "" {
78 +
			_ = updateSubscriptionFavicon(a.DB, sub.ID, &fav)
79 +
			sub.FaviconURL = sql.NullString{String: fav, Valid: true}
80 +
		}
81 +
	}
82 +
	if err := a.seedSubscription(sub.ID, res); err != nil {
83 +
		return nil, err
84 +
	}
85 +
	return getSubscription(a.DB, sub.ID)
86 +
}
87 +
88 +
func (a *App) seedSubscription(subID int64, res *FetchResult) error {
89 +
	for _, entry := range res.Entries {
90 +
		_, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: subID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt})
91 +
		if err != nil {
92 +
			a.Log.Warn("seed insert failed", "sub_id", subID, "err", err)
93 +
		}
94 +
	}
95 +
	if err := pruneSubscription(a.DB, subID, a.ItemCap); err != nil {
96 +
		return err
97 +
	}
98 +
	return updateSubscriptionMeta(a.DB, subID, stringPtr(res.ETag), stringPtr(res.LastModified), nil)
99 +
}
100 +
101 +
func (a *App) resolveCategory(id *int64, name string) (*int64, error) {
102 +
	if id != nil {
103 +
		return id, nil
104 +
	}
105 +
	if strings.TrimSpace(name) == "" {
106 +
		return nil, nil
107 +
	}
108 +
	cat, err := getOrCreateCategory(a.DB, name)
109 +
	if err != nil || cat == nil {
110 +
		return nil, err
111 +
	}
112 +
	return &cat.ID, nil
113 +
}
114 +
115 +
func (a *App) resolveSubscriptionCategory(body updateSubscriptionBody) (*int64, error) {
116 +
	if body.ClearCategory {
117 +
		return nil, nil
118 +
	}
119 +
	return a.resolveCategory(body.CategoryID, body.CategoryName)
120 +
}
121 +
122 +
func (a *App) readAndImportOPML(r *http.Request) (*importSummary, error) {
123 +
	if err := r.ParseMultipartForm(8 << 20); err != nil {
124 +
		return nil, err
125 +
	}
126 +
	file, _, err := r.FormFile("file")
127 +
	if err != nil {
128 +
		return nil, err
129 +
	}
130 +
	defer file.Close()
131 +
	body, err := io.ReadAll(file)
132 +
	if err != nil {
133 +
		return nil, err
134 +
	}
135 +
	return a.importOPMLString(r.Context(), string(body)), nil
136 +
}
137 +
138 +
func (a *App) importOPMLString(ctx context.Context, content string) *importSummary {
139 +
	entries := parseOPML(content)
140 +
	summary := &importSummary{}
141 +
	for _, entry := range entries {
142 +
		existing, _ := getSubscriptionByURL(a.DB, entry.XMLURL)
143 +
		if existing != nil {
144 +
			summary.Skipped++
145 +
			continue
146 +
		}
147 +
		body := createSubscriptionBody{FeedURL: entry.XMLURL, Title: entry.Title, CategoryName: entry.Category}
148 +
		if _, err := a.createSubscription(ctx, body, true); err != nil {
149 +
			summary.Failed = append(summary.Failed, fmt.Sprintf("%s: %v", entry.XMLURL, err))
150 +
			continue
151 +
		}
152 +
		summary.Imported++
153 +
	}
154 +
	return summary
155 +
}
156 +
157 +
func (a *App) poller(ctx context.Context) {
158 +
	time.Sleep(3 * time.Second)
159 +
	for {
160 +
		mins := a.pollIntervalMinutes()
161 +
		a.Log.Info("poller pass starting", "interval_minutes", mins)
162 +
		subs, err := listSubscriptions(a.DB)
163 +
		if err == nil {
164 +
			for _, sub := range subs {
165 +
				if err := a.pollOne(ctx, sub); err != nil {
166 +
					msg := err.Error()
167 +
					_ = updateSubscriptionMeta(a.DB, sub.ID, nullStringPointer(sub.ETag), nullStringPointer(sub.LastModified), &msg)
168 +
					a.Log.Warn("feed poll failed", "feed_url", sub.FeedURL, "err", err)
169 +
				}
170 +
			}
171 +
		}
172 +
		time.Sleep(time.Duration(mins) * time.Minute)
173 +
	}
174 +
}
175 +
176 +
func (a *App) pollOne(ctx context.Context, sub Subscription) error {
177 +
	res, err := fetchFeed(ctx, sub.FeedURL, nullStringValue(sub.ETag), nullStringValue(sub.LastModified))
178 +
	if err != nil {
179 +
		return err
180 +
	}
181 +
	inserted := 0
182 +
	if res.Status != http.StatusNotModified {
183 +
		for _, entry := range res.Entries {
184 +
			ok, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: sub.ID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt})
185 +
			if err != nil {
186 +
				a.Log.Warn("insert item failed", "feed_url", sub.FeedURL, "err", err)
187 +
				continue
188 +
			}
189 +
			if ok {
190 +
				inserted++
191 +
			}
192 +
		}
193 +
		if res.Title != "" && sub.Title == sub.FeedURL && res.Title != sub.Title {
194 +
			_ = updateSubscriptionTitle(a.DB, sub.ID, res.Title)
195 +
		}
196 +
		if err := pruneSubscription(a.DB, sub.ID, a.ItemCap); err != nil {
197 +
			return err
198 +
		}
199 +
	}
200 +
	if err := updateSubscriptionMeta(a.DB, sub.ID, stringPtr(res.ETag), stringPtr(res.LastModified), nil); err != nil {
201 +
		return err
202 +
	}
203 +
	a.Log.Info("feed polled", "feed_url", sub.FeedURL, "status", res.Status, "new_items", inserted, "entries", len(res.Entries))
204 +
	return nil
205 +
}
206 +
207 +
func (a *App) pollIntervalMinutes() int {
208 +
	if value, ok, err := getSetting(a.DB, "poll_interval_minutes"); err == nil && ok {
209 +
		if mins, err := parsePositiveInt(value); err == nil {
210 +
			return mins
211 +
		}
212 +
	}
213 +
	return a.DefaultPollMinutes
214 +
}
215 +
216 +
func isAlreadySubscribedError(err error) bool {
217 +
	return err != nil && strings.Contains(err.Error(), errAlreadySubscribed)
218 +
}
apps/feeds-go/templates/admin.html (added) +169 −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>Feeds | Admin</title>
14 +
  </head>
15 +
  <body>
16 +
    <div class="header">
17 +
      <a href="/" class="logo"><h1>FEEDS</h1></a>
18 +
      <nav class="links"><a href="/feeds?format=opml">opml</a><a href="/admin/logout">logout</a></nav>
19 +
    </div>
20 +
21 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
22 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
24 +
    <section class="admin-form">
25 +
      <h3>Discover</h3>
26 +
      <div class="discover-row">
27 +
        <input type="url" id="base_url" placeholder="https://example.com" />
28 +
        <button type="button" id="discover-btn" onclick="discoverFeeds()">Discover</button>
29 +
      </div>
30 +
      <div id="discover-status" class="discover-status" style="display:none;"></div>
31 +
      <div id="discover-results" class="discover-results" style="display:none;"></div>
32 +
    </section>
33 +
34 +
    <form class="admin-form" id="add-feed-form" method="POST" action="/admin/add-feed">
35 +
      <h3>Add Feed</h3>
36 +
      <label for="feed_url">Feed URL</label>
37 +
      <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required />
38 +
      <label for="category_name">Category (optional)</label>
39 +
      <input type="text" id="category_name" name="category_name" placeholder="Tech" list="categories-list" />
40 +
      <datalist id="categories-list">{{range .Categories}}<option value="{{.Name}}"></option>{{end}}</datalist>
41 +
      <button type="submit" id="add-feed-submit"><span id="add-feed-label">Add Feed</span></button>
42 +
    </form>
43 +
44 +
    <form class="admin-form" id="opml-form" method="POST" action="/admin/import-opml" enctype="multipart/form-data">
45 +
      <h3>Import OPML</h3>
46 +
      <input type="file" name="file" accept=".opml,.xml,application/xml,text/xml" required />
47 +
      <button type="submit" id="opml-submit"><span id="opml-submit-label">Import</span></button>
48 +
    </form>
49 +
50 +
    <form class="admin-form" method="POST" action="/admin/settings">
51 +
      <h3>Settings</h3>
52 +
      <label for="poll_interval_minutes">Poll interval (minutes)</label>
53 +
      <input type="number" id="poll_interval_minutes" name="poll_interval_minutes" min="1" max="1440" value="{{.PollIntervalMinutes}}" required />
54 +
      <p class="hint">Item cap per feed: {{.ItemCap}} (set via ITEM_CAP_PER_FEED)</p>
55 +
      <p class="hint">API key: {{if .APIKeyConfigured}}configured{{else}}not set{{end}}</p>
56 +
      <button type="submit">Save</button>
57 +
    </form>
58 +
59 +
    <section class="admin-subs">
60 +
      <h3>Categories ({{len .Categories}})</h3>
61 +
      <form class="admin-form inline" method="POST" action="/admin/categories">
62 +
        <input type="text" name="name" placeholder="New category" required />
63 +
        <button type="submit">Add</button>
64 +
      </form>
65 +
      <ul class="category-list">
66 +
        {{range .Categories}}
67 +
        <li>
68 +
          <span>{{.Name}}</span>
69 +
          <form method="POST" action="/admin/categories/{{.ID}}/delete" class="inline"><button type="submit" class="danger">Delete</button></form>
70 +
        </li>
71 +
        {{end}}
72 +
      </ul>
73 +
    </section>
74 +
75 +
    <section class="admin-subs">
76 +
      <h3>Subscriptions ({{len .Subscriptions}})</h3>
77 +
      <div class="feeds-list">
78 +
        {{range .Subscriptions}}
79 +
        <div class="feed-item">
80 +
          <h3 class="feed-title"><a href="{{.SiteURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></h3>
81 +
          {{if .LastFetchedAt}}<p class="feed-meta"><span class="feed-date">last: {{.LastFetchedAt}}</span>{{if .LastError}} <span class="error">· {{.LastError}}</span>{{end}}</p>{{end}}
82 +
          <form method="POST" action="/admin/feeds/{{.ID}}/category" class="inline">
83 +
            <input type="text" name="category_name" placeholder="category" list="categories-list" value="{{.CategoryName}}" />
84 +
            <button type="submit">Save</button>
85 +
          </form>
86 +
          <form method="POST" action="/admin/feeds/{{.ID}}/delete" class="inline"><button type="submit" class="danger">Delete</button></form>
87 +
        </div>
88 +
        {{end}}
89 +
      </div>
90 +
    </section>
91 +
92 +
    <script>
93 +
      (function() {
94 +
        const form = document.getElementById('add-feed-form');
95 +
        if (!form) return;
96 +
        form.addEventListener('submit', function() {
97 +
          const btn = document.getElementById('add-feed-submit');
98 +
          const label = document.getElementById('add-feed-label');
99 +
          btn.disabled = true;
100 +
          btn.classList.add('loading');
101 +
          label.innerHTML = 'Adding <span class="spinner"></span>';
102 +
        });
103 +
      })();
104 +
105 +
      (function() {
106 +
        const form = document.getElementById('opml-form');
107 +
        if (!form) return;
108 +
        form.addEventListener('submit', function() {
109 +
          const btn = document.getElementById('opml-submit');
110 +
          const label = document.getElementById('opml-submit-label');
111 +
          btn.disabled = true;
112 +
          btn.classList.add('loading');
113 +
          label.innerHTML = 'Importing <span class="spinner"></span>';
114 +
        });
115 +
      })();
116 +
117 +
      async function discoverFeeds() {
118 +
        const baseUrl = document.getElementById('base_url').value.trim();
119 +
        if (!baseUrl) return;
120 +
        const btn = document.getElementById('discover-btn');
121 +
        const status = document.getElementById('discover-status');
122 +
        const results = document.getElementById('discover-results');
123 +
        const feedInput = document.getElementById('feed_url');
124 +
        btn.disabled = true;
125 +
        btn.textContent = 'Searching...';
126 +
        status.style.display = 'none';
127 +
        results.style.display = 'none';
128 +
        results.innerHTML = '';
129 +
        try {
130 +
          const body = new URLSearchParams({ base_url: baseUrl });
131 +
          const resp = await fetch('/admin/discover-feeds', { method: 'POST', body });
132 +
          const data = await resp.json();
133 +
          if (!resp.ok) {
134 +
            status.textContent = data.error || 'No feeds found';
135 +
            status.className = 'discover-status error';
136 +
            status.style.display = 'block';
137 +
            return;
138 +
          }
139 +
          feedInput.value = data[0];
140 +
          status.textContent = data.length + ' feed(s) found';
141 +
          status.className = 'discover-status success';
142 +
          status.style.display = 'block';
143 +
          if (data.length > 1) {
144 +
            results.style.display = 'flex';
145 +
            data.forEach(function(url) {
146 +
              const item = document.createElement('button');
147 +
              item.type = 'button';
148 +
              item.className = 'discover-result-item' + (url === data[0] ? ' active' : '');
149 +
              item.textContent = url;
150 +
              item.onclick = function() {
151 +
                feedInput.value = url;
152 +
                results.querySelectorAll('.discover-result-item').forEach(function(el) { el.classList.remove('active'); });
153 +
                item.classList.add('active');
154 +
              };
155 +
              results.appendChild(item);
156 +
            });
157 +
          }
158 +
        } catch (e) {
159 +
          status.textContent = 'Request failed';
160 +
          status.className = 'discover-status error';
161 +
          status.style.display = 'block';
162 +
        } finally {
163 +
          btn.disabled = false;
164 +
          btn.textContent = 'Discover';
165 +
        }
166 +
      }
167 +
    </script>
168 +
  </body>
169 +
</html>
apps/feeds-go/templates/index.html (added) +51 −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>Feeds</title>
14 +
    <meta name="description" content="Minimal RSS Reading">
15 +
    <meta property="og:url" content="{{.BaseURL}}">
16 +
    <meta property="og:type" content="website">
17 +
    <meta property="og:title" content="Feeds">
18 +
    <meta property="og:description" content="Minimal RSS Reading">
19 +
    <meta property="og:image" content="{{.BaseURL}}/static/og.png">
20 +
  </head>
21 +
  <body>
22 +
    <div class="header">
23 +
      <a href="/" class="logo"><h1>FEEDS</h1></a>
24 +
      <nav class="links"><a href="/admin">add</a></nav>
25 +
    </div>
26 +
27 +
    {{if .FeedURLs}}
28 +
    <div id="feed-urls">
29 +
      {{range .FeedURLs}}{{.}}<br>{{end}}
30 +
    </div>
31 +
    {{end}}
32 +
33 +
    {{if .Error}}
34 +
    <div id="error" class="error"><p>{{.Error}}</p></div>
35 +
    {{else if not .Items}}
36 +
    <p class="no-feeds">No feeds available</p>
37 +
    {{else}}
38 +
    <div id="feeds-container">
39 +
      <div class="feeds-list">
40 +
        {{range .Items}}
41 +
        <article class="feed-item">
42 +
          <div class="feed-meta"><span class="feed-date">{{.FormattedDate}}</span></div>
43 +
          <h3 class="feed-title"><a href="{{.Link}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></h3>
44 +
          {{if .Author}}<p class="feed-author">{{.Author}}</p>{{end}}
45 +
        </article>
46 +
        {{end}}
47 +
      </div>
48 +
    </div>
49 +
    {{end}}
50 +
  </body>
51 +
</html>
apps/feeds-go/templates/login.html (added) +24 −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>Feeds | Login</title>
14 +
  </head>
15 +
  <body>
16 +
    <a href="/" class="header"><h1>FEEDS</h1></a>
17 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
18 +
    <form class="admin-form" method="POST" action="/admin/login">
19 +
      <label for="password">Password</label>
20 +
      <input type="password" id="password" name="password" required autofocus />
21 +
      <button type="submit">Login</button>
22 +
    </form>
23 +
  </body>
24 +
</html>
apps/feeds-go/util.go (added) +102 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"fmt"
6 +
	"net/http"
7 +
	"strconv"
8 +
	"strings"
9 +
	"time"
10 +
)
11 +
12 +
func formatDate(ts int64) string {
13 +
	if ts <= 0 {
14 +
		return ""
15 +
	}
16 +
	return time.Unix(ts, 0).UTC().Format("Jan 2, 2006")
17 +
}
18 +
19 +
func parseIntDefault(s string, fallback int) int {
20 +
	if v, err := strconv.Atoi(s); err == nil {
21 +
		return v
22 +
	}
23 +
	return fallback
24 +
}
25 +
26 +
func parsePositiveInt(s string) (int, error) {
27 +
	v, err := strconv.Atoi(strings.TrimSpace(s))
28 +
	if err != nil || v < 1 {
29 +
		return 0, fmt.Errorf("invalid integer")
30 +
	}
31 +
	return v, nil
32 +
}
33 +
34 +
func validPollMinutes(v int) bool {
35 +
	return v >= 1 && v <= 1440
36 +
}
37 +
38 +
func formPollMinutes(r *http.Request) (int, bool) {
39 +
	mins, err := strconv.Atoi(r.FormValue("poll_interval_minutes"))
40 +
	return mins, err == nil && validPollMinutes(mins)
41 +
}
42 +
43 +
func itemFilterFromRequest(r *http.Request) ListItemsFilter {
44 +
	filter := ListItemsFilter{Limit: parseIntDefault(r.URL.Query().Get("limit"), 100), UnreadOnly: r.URL.Query().Get("unread") == "true"}
45 +
	if id, ok := queryInt64(r, "category_id"); ok {
46 +
		filter.CategoryID = &id
47 +
	}
48 +
	if id, ok := queryInt64(r, "subscription_id"); ok {
49 +
		filter.SubscriptionID = &id
50 +
	}
51 +
	return filter
52 +
}
53 +
54 +
func queryInt64(r *http.Request, key string) (int64, bool) {
55 +
	v := strings.TrimSpace(r.URL.Query().Get(key))
56 +
	if v == "" {
57 +
		return 0, false
58 +
	}
59 +
	id, err := strconv.ParseInt(v, 10, 64)
60 +
	if err != nil {
61 +
		return 0, false
62 +
	}
63 +
	return id, true
64 +
}
65 +
66 +
func splitAndTrim(s string) []string {
67 +
	parts := strings.Split(s, ",")
68 +
	out := []string{}
69 +
	for _, part := range parts {
70 +
		if trimmed := strings.TrimSpace(part); trimmed != "" {
71 +
			out = append(out, trimmed)
72 +
		}
73 +
	}
74 +
	return out
75 +
}
76 +
77 +
func nullStringValue(v sql.NullString) string {
78 +
	if v.Valid {
79 +
		return v.String
80 +
	}
81 +
	return ""
82 +
}
83 +
84 +
func nullStringPointer(v sql.NullString) *string {
85 +
	if v.Valid {
86 +
		return &v.String
87 +
	}
88 +
	return nil
89 +
}
90 +
91 +
func toSubscriptionView(s Subscription) subscriptionView {
92 +
	return subscriptionView{ID: s.ID, FeedURL: s.FeedURL, Title: s.Title, SiteURL: nullStringPointer(s.SiteURL), FaviconURL: nullStringPointer(s.FaviconURL), CategoryID: func() *int64 {
93 +
		if s.CategoryID.Valid {
94 +
			return &s.CategoryID.Int64
95 +
		}
96 +
		return nil
97 +
	}(), ETag: nullStringPointer(s.ETag), LastModified: nullStringPointer(s.LastModified), LastFetchedAt: nullStringPointer(s.LastFetchedAt), LastError: nullStringPointer(s.LastError), AddedAt: s.AddedAt}
98 +
}
99 +
100 +
func itoa(v int) string {
101 +
	return strconv.Itoa(v)
102 +
}
apps/jotts-go/.env.example (added) +8 −0
1 +
JOTTS_PASSWORD=changeme
2 +
JOTTS_DB_PATH=jotts.sqlite
3 +
COOKIE_SECURE=false
4 +
HOST=127.0.0.1
5 +
PORT=3000
6 +
# Optional. When set, enables the JSON API at /api/notes gated by x-api-key header.
7 +
# Leave unset to disable the API (returns 403).
8 +
JOTTS_API_KEY=
apps/jotts-go/Dockerfile (added) +14 −0
1 +
FROM golang:1.24-bookworm AS builder
2 +
WORKDIR /app
3 +
COPY apps/jotts-go/go.mod apps/jotts-go/go.sum ./
4 +
RUN go mod download
5 +
COPY apps/jotts-go/ ./
6 +
RUN CGO_ENABLED=0 go build -o /jotts-go .
7 +
8 +
FROM debian:bookworm-slim
9 +
COPY --from=builder /jotts-go /usr/local/bin/jotts-go
10 +
WORKDIR /data
11 +
ENV HOST=0.0.0.0
12 +
ENV PORT=3000
13 +
EXPOSE 3000
14 +
CMD ["jotts-go"]
apps/jotts-go/README.md (added) +75 −0
1 +
# jotts-go
2 +
3 +
Go port of [jotts](../jotts): minimal markdown notes app.
4 +
5 +
## Stack
6 +
7 +
- Go stdlib `net/http` + `html/template`
8 +
- `modernc.org/sqlite` (pure-Go SQLite, no CGO)
9 +
- `github.com/yuin/goldmark` (markdown rendering w/ strikethrough, tables, tasklists)
10 +
11 +
No other dependencies.
12 +
13 +
## Quickstart
14 +
15 +
```bash
16 +
cp .env.example .env
17 +
# edit .env with your password
18 +
go run .
19 +
```
20 +
21 +
## Environment variables
22 +
23 +
| Variable | Description | Default |
24 +
|---|---|---|
25 +
| `JOTTS_PASSWORD` | Login password | `changeme` |
26 +
| `JOTTS_DB_PATH` | SQLite file path | `jotts.sqlite` |
27 +
| `HOST` | Bind address | `127.0.0.1` |
28 +
| `PORT` | Server port | `3000` |
29 +
| `COOKIE_SECURE` | HTTPS-only cookies | `false` |
30 +
| `JOTTS_API_KEY` | API key for `/api/notes` (unset = API disabled) | _(unset)_ |
31 +
32 +
## Structure
33 +
34 +
```
35 +
jotts-go/
36 +
├── main.go           # entrypoint
37 +
├── app.go            # App struct + page data types
38 +
├── db.go             # SQLite schema + queries (notes, sessions)
39 +
├── routes.go         # http.ServeMux routes
40 +
├── middleware.go     # session + API key middleware, cookies
41 +
├── handlers_web.go   # HTML form handlers
42 +
├── handlers_api.go   # JSON API handlers
43 +
├── markdown.go       # goldmark rendering
44 +
├── web.go            # template render, JSON, embedded static
45 +
├── util.go           # env, dotenv, short IDs, session tokens
46 +
├── templates/        # html/template pages
47 +
├── static/           # favicons, styles, og image
48 +
├── assets/           # darkmatter.css + Commit Mono fonts
49 +
├── Dockerfile
50 +
└── docker-compose.yml
51 +
```
52 +
53 +
## API
54 +
55 +
All endpoints require `x-api-key: $JOTTS_API_KEY` header.
56 +
57 +
- `GET /api/notes` — list notes
58 +
- `POST /api/notes` — create `{title, content}`
59 +
- `GET /api/notes/{short_id}`
60 +
- `PUT /api/notes/{short_id}` — update `{title, content}`
61 +
- `DELETE /api/notes/{short_id}`
62 +
63 +
## Build
64 +
65 +
```bash
66 +
CGO_ENABLED=0 go build -o jotts-go .
67 +
```
68 +
69 +
Single ~10MB self-contained binary with all assets embedded.
70 +
71 +
## Docker
72 +
73 +
```bash
74 +
docker compose up -d
75 +
```
apps/jotts-go/app.go (added) +49 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/internal/store"
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 +
	Password     string
22 +
	APIKey       string
23 +
	CookieSecure bool
24 +
}
25 +
26 +
type Note = store.Note
27 +
type NoteInput = store.NoteInput
28 +
29 +
type indexPageData struct {
30 +
	Notes []Note
31 +
}
32 +
33 +
type loginPageData struct {
34 +
	Error string
35 +
}
36 +
37 +
type newPageData struct {
38 +
	Error string
39 +
}
40 +
41 +
type editPageData struct {
42 +
	Note  Note
43 +
	Error string
44 +
}
45 +
46 +
type viewPageData struct {
47 +
	Note     Note
48 +
	Rendered template.HTML
49 +
}
apps/jotts-go/cmd_auth.go (added) +48 −0
1 +
package main
2 +
3 +
import (
4 +
	"bufio"
5 +
	"fmt"
6 +
	"os"
7 +
	"strings"
8 +
	"syscall"
9 +
10 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/tui"
11 +
	"golang.org/x/term"
12 +
)
13 +
14 +
func runAuth(_ []string) {
15 +
	cfg, _ := tui.LoadConfig()
16 +
	reader := bufio.NewReader(os.Stdin)
17 +
18 +
	defaultURL := cfg.RemoteURL
19 +
	if defaultURL == "" {
20 +
		defaultURL = "http://localhost:3000"
21 +
	}
22 +
	fmt.Printf("Remote URL [%s]: ", defaultURL)
23 +
	line, _ := reader.ReadString('\n')
24 +
	line = strings.TrimSpace(line)
25 +
	if line != "" {
26 +
		cfg.RemoteURL = line
27 +
	} else {
28 +
		cfg.RemoteURL = defaultURL
29 +
	}
30 +
31 +
	fmt.Print("API key (hidden): ")
32 +
	keyBytes, err := term.ReadPassword(int(syscall.Stdin))
33 +
	fmt.Println()
34 +
	if err != nil {
35 +
		fmt.Fprintln(os.Stderr, "read api key:", err)
36 +
		os.Exit(1)
37 +
	}
38 +
	if k := strings.TrimSpace(string(keyBytes)); k != "" {
39 +
		cfg.APIKey = k
40 +
	}
41 +
42 +
	if err := tui.SaveConfig(cfg); err != nil {
43 +
		fmt.Fprintln(os.Stderr, "save config:", err)
44 +
		os.Exit(1)
45 +
	}
46 +
	path, _ := tui.ConfigPath()
47 +
	fmt.Println("Saved", path)
48 +
}
apps/jotts-go/cmd_server.go (added) +58 −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 runServer(args []string) {
15 +
	config.LoadDotEnv(".env")
16 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 +
18 +
	dbPath := config.Getenv("JOTTS_DB_PATH", "jotts.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 +
33 +
	password := os.Getenv("JOTTS_PASSWORD")
34 +
	if password == "" {
35 +
		logger.Warn("JOTTS_PASSWORD not set, using default 'changeme'")
36 +
		password = "changeme"
37 +
	}
38 +
	apiKey := os.Getenv("JOTTS_API_KEY")
39 +
	if apiKey == "" {
40 +
		logger.Info("JOTTS_API_KEY not set, /api/* will return 403")
41 +
	}
42 +
43 +
	app := &App{
44 +
		DB:           db,
45 +
		Log:          logger,
46 +
		Templates:    tmpl,
47 +
		Sessions:     sessions,
48 +
		Password:     password,
49 +
		APIKey:       apiKey,
50 +
		CookieSecure: sessions.CookieSecure,
51 +
	}
52 +
53 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
54 +
	logger.Info("jotts-go server running", "addr", addr)
55 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
56 +
		log.Fatal(err)
57 +
	}
58 +
}
apps/jotts-go/cmd_upload.go (added) +47 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"path/filepath"
7 +
	"strings"
8 +
9 +
	"github.com/atotto/clipboard"
10 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/tui"
11 +
)
12 +
13 +
func runUpload(args []string) {
14 +
	path := args[0]
15 +
	data, err := os.ReadFile(path)
16 +
	if err != nil {
17 +
		fmt.Fprintln(os.Stderr, "read file:", err)
18 +
		os.Exit(1)
19 +
	}
20 +
	title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
21 +
	if title == "" {
22 +
		title = "Untitled"
23 +
	}
24 +
25 +
	backend, err := tui.ResolveBackend(tui.ParseArgs(args[1:]))
26 +
	if err != nil {
27 +
		fmt.Fprintln(os.Stderr, "backend:", err)
28 +
		os.Exit(1)
29 +
	}
30 +
	defer backend.Close()
31 +
32 +
	note, err := backend.Create(title, string(data))
33 +
	if err != nil {
34 +
		fmt.Fprintln(os.Stderr, "create note:", err)
35 +
		os.Exit(1)
36 +
	}
37 +
38 +
	if remote := backend.RemoteURL(); remote != "" {
39 +
		link := strings.TrimRight(remote, "/") + "/notes/" + note.ShortID
40 +
		fmt.Println(link)
41 +
		if err := clipboard.WriteAll(link); err == nil {
42 +
			fmt.Println("(copied to clipboard)")
43 +
		}
44 +
	} else {
45 +
		fmt.Println("created:", note.ShortID)
46 +
	}
47 +
}
apps/jotts-go/db.go (added) +27 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
6 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/internal/store"
7 +
)
8 +
9 +
func openDB(path string) (*sql.DB, error) { return store.Open(path) }
10 +
11 +
func createNote(db *sql.DB, title, content string) (*Note, error) {
12 +
	return store.Create(db, title, content)
13 +
}
14 +
15 +
func getNoteByShortID(db *sql.DB, shortID string) (*Note, error) {
16 +
	return store.GetByShortID(db, shortID)
17 +
}
18 +
19 +
func listNotes(db *sql.DB) ([]Note, error) { return store.List(db) }
20 +
21 +
func updateNoteByShortID(db *sql.DB, shortID, title, content string) (*Note, error) {
22 +
	return store.UpdateByShortID(db, shortID, title, content)
23 +
}
24 +
25 +
func deleteNoteByShortID(db *sql.DB, shortID string) (bool, error) {
26 +
	return store.DeleteByShortID(db, shortID)
27 +
}
apps/jotts-go/docker-compose.yml (added) +20 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/jotts-go/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme}
10 +
      - JOTTS_DB_PATH=/data/jotts.sqlite
11 +
      - COOKIE_SECURE=false
12 +
      - HOST=0.0.0.0
13 +
      - PORT=${PORT:-3000}
14 +
      - JOTTS_API_KEY=${JOTTS_API_KEY:-}
15 +
    volumes:
16 +
      - jotts-go-data:/data
17 +
    restart: unless-stopped
18 +
19 +
volumes:
20 +
  jotts-go-data:
apps/jotts-go/go.mod (added) +69 −0
1 +
module github.com/stevedylandev/andromeda/apps/jotts-go
2 +
3 +
go 1.25.0
4 +
5 +
require (
6 +
	github.com/BurntSushi/toml v1.6.0
7 +
	github.com/atotto/clipboard v0.1.4
8 +
	github.com/charmbracelet/bubbles v1.0.0
9 +
	github.com/charmbracelet/bubbletea v1.3.10
10 +
	github.com/charmbracelet/glamour v1.0.0
11 +
	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
12 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
13 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
14 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
15 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
16 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
17 +
	github.com/yuin/goldmark v1.7.13
18 +
	golang.org/x/term v0.43.0
19 +
)
20 +
21 +
replace (
22 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
23 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
24 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
25 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
26 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
27 +
)
28 +
29 +
require (
30 +
	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
31 +
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
32 +
	github.com/aymerick/douceur v0.2.0 // indirect
33 +
	github.com/charmbracelet/colorprofile v0.4.1 // indirect
34 +
	github.com/charmbracelet/x/ansi v0.11.6 // indirect
35 +
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
36 +
	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
37 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
38 +
	github.com/clipperhouse/displaywidth v0.9.0 // indirect
39 +
	github.com/clipperhouse/stringish v0.1.1 // indirect
40 +
	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
41 +
	github.com/dlclark/regexp2 v1.11.5 // indirect
42 +
	github.com/dustin/go-humanize v1.0.1 // indirect
43 +
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
44 +
	github.com/google/uuid v1.6.0 // indirect
45 +
	github.com/gorilla/css v1.0.1 // indirect
46 +
	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
47 +
	github.com/mattn/go-isatty v0.0.20 // indirect
48 +
	github.com/mattn/go-localereader v0.0.1 // indirect
49 +
	github.com/mattn/go-runewidth v0.0.19 // indirect
50 +
	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
51 +
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
52 +
	github.com/muesli/cancelreader v0.2.2 // indirect
53 +
	github.com/muesli/reflow v0.3.0 // indirect
54 +
	github.com/muesli/termenv v0.16.0 // indirect
55 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
56 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
57 +
	github.com/rivo/uniseg v0.4.7 // indirect
58 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
59 +
	github.com/yuin/goldmark-emoji v1.0.6 // indirect
60 +
	golang.org/x/crypto v0.39.0 // indirect
61 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
62 +
	golang.org/x/net v0.38.0 // indirect
63 +
	golang.org/x/sys v0.44.0 // indirect
64 +
	golang.org/x/text v0.30.0 // indirect
65 +
	modernc.org/libc v1.65.7 // indirect
66 +
	modernc.org/mathutil v1.7.1 // indirect
67 +
	modernc.org/memory v1.11.0 // indirect
68 +
	modernc.org/sqlite v1.37.1 // indirect
69 +
)
apps/jotts-go/go.sum (added) +135 −0
1 +
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 +
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 +
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4 +
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
5 +
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
6 +
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
7 +
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
8 +
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
9 +
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
10 +
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11 +
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
12 +
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
13 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
14 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
15 +
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
16 +
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
17 +
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
18 +
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
19 +
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
20 +
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
21 +
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
22 +
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
23 +
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
24 +
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
25 +
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
26 +
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
27 +
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
28 +
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
29 +
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
30 +
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
31 +
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
32 +
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
33 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
34 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
35 +
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
36 +
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
37 +
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
38 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
39 +
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
40 +
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
41 +
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
42 +
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
43 +
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
44 +
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
45 +
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
46 +
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
47 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
48 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
49 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
50 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
51 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
52 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
53 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
54 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
55 +
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
56 +
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
57 +
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
58 +
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
59 +
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
60 +
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
61 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
62 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
63 +
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
64 +
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
65 +
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
66 +
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
67 +
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
68 +
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
69 +
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
70 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
71 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
72 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
73 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
74 +
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
75 +
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
76 +
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
77 +
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
78 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
79 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
80 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
81 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
82 +
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
83 +
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
84 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
85 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
86 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
87 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
88 +
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
89 +
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
90 +
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
91 +
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
92 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
93 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
94 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
95 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
96 +
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
97 +
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
98 +
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
99 +
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
100 +
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
101 +
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
102 +
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
103 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104 +
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
105 +
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
106 +
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
107 +
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
108 +
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
109 +
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
110 +
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
111 +
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
112 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
113 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
114 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
115 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
116 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
117 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
118 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
119 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
120 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
121 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
122 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
123 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
124 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
125 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
126 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
127 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
128 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
129 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
130 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
131 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
132 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
133 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
134 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
135 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/jotts-go/handlers_api.go (added) +89 −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) apiListNotes(w http.ResponseWriter, r *http.Request) {
11 +
	notes, err := listNotes(a.DB)
12 +
	if err != nil {
13 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
14 +
		return
15 +
	}
16 +
	if notes == nil {
17 +
		notes = []Note{}
18 +
	}
19 +
	web.WriteJSON(w, http.StatusOK, notes)
20 +
}
21 +
22 +
func (a *App) apiGetNote(w http.ResponseWriter, r *http.Request) {
23 +
	shortID := r.PathValue("short_id")
24 +
	note, err := getNoteByShortID(a.DB, shortID)
25 +
	if err != nil {
26 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
27 +
		return
28 +
	}
29 +
	if note == nil {
30 +
		http.NotFound(w, r)
31 +
		return
32 +
	}
33 +
	web.WriteJSON(w, http.StatusOK, note)
34 +
}
35 +
36 +
func (a *App) apiCreateNote(w http.ResponseWriter, r *http.Request) {
37 +
	var body NoteInput
38 +
	if !web.DecodeJSON(w, r, &body) {
39 +
		return
40 +
	}
41 +
	title := strings.TrimSpace(body.Title)
42 +
	if title == "" {
43 +
		http.Error(w, "title required", http.StatusBadRequest)
44 +
		return
45 +
	}
46 +
	note, err := createNote(a.DB, title, body.Content)
47 +
	if err != nil {
48 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
49 +
		return
50 +
	}
51 +
	web.WriteJSON(w, http.StatusCreated, note)
52 +
}
53 +
54 +
func (a *App) apiUpdateNote(w http.ResponseWriter, r *http.Request) {
55 +
	shortID := r.PathValue("short_id")
56 +
	var body NoteInput
57 +
	if !web.DecodeJSON(w, r, &body) {
58 +
		return
59 +
	}
60 +
	title := strings.TrimSpace(body.Title)
61 +
	if title == "" {
62 +
		http.Error(w, "title required", http.StatusBadRequest)
63 +
		return
64 +
	}
65 +
	note, err := updateNoteByShortID(a.DB, shortID, title, body.Content)
66 +
	if err != nil {
67 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
68 +
		return
69 +
	}
70 +
	if note == nil {
71 +
		http.NotFound(w, r)
72 +
		return
73 +
	}
74 +
	web.WriteJSON(w, http.StatusOK, note)
75 +
}
76 +
77 +
func (a *App) apiDeleteNote(w http.ResponseWriter, r *http.Request) {
78 +
	shortID := r.PathValue("short_id")
79 +
	ok, err := deleteNoteByShortID(a.DB, shortID)
80 +
	if err != nil {
81 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
82 +
		return
83 +
	}
84 +
	if !ok {
85 +
		http.NotFound(w, r)
86 +
		return
87 +
	}
88 +
	w.WriteHeader(http.StatusNoContent)
89 +
}
apps/jotts-go/handlers_web.go (added) +144 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
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) loginGetHandler(w http.ResponseWriter, r *http.Request) {
13 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
14 +
}
15 +
16 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
17 +
	if err := r.ParseForm(); err != nil {
18 +
		web.RedirectWithError(w, r, "/login", "Invalid request")
19 +
		return
20 +
	}
21 +
	if !auth.SecureEqual(r.FormValue("password"), a.Password) {
22 +
		web.RedirectWithError(w, r, "/login", "Invalid password")
23 +
		return
24 +
	}
25 +
	token, err := a.Sessions.Create()
26 +
	if err != nil {
27 +
		a.Log.Error("create session failed", "err", err)
28 +
		web.RedirectWithError(w, r, "/login", "Server error")
29 +
		return
30 +
	}
31 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
32 +
	http.Redirect(w, r, "/", http.StatusSeeOther)
33 +
}
34 +
35 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
36 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
37 +
		a.Sessions.Delete(c.Value)
38 +
	}
39 +
	http.SetCookie(w, a.Sessions.ClearCookie())
40 +
	http.Redirect(w, r, "/login", http.StatusSeeOther)
41 +
}
42 +
43 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
44 +
	notes, err := listNotes(a.DB)
45 +
	if err != nil {
46 +
		a.Log.Error("list notes failed", "err", err)
47 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
48 +
		return
49 +
	}
50 +
	web.Render(a.Templates, w, "index.html", indexPageData{Notes: notes}, a.Log)
51 +
}
52 +
53 +
func (a *App) newNoteGetHandler(w http.ResponseWriter, r *http.Request) {
54 +
	web.Render(a.Templates, w, "new.html", newPageData{Error: r.URL.Query().Get("error")}, a.Log)
55 +
}
56 +
57 +
func (a *App) createNoteHandler(w http.ResponseWriter, r *http.Request) {
58 +
	if err := r.ParseForm(); err != nil {
59 +
		web.RedirectWithError(w, r, "/notes/new", "Invalid request")
60 +
		return
61 +
	}
62 +
	title := strings.TrimSpace(r.FormValue("title"))
63 +
	content := r.FormValue("content")
64 +
	if title == "" {
65 +
		web.RedirectWithError(w, r, "/notes/new", "Title is required")
66 +
		return
67 +
	}
68 +
	note, err := createNote(a.DB, title, content)
69 +
	if err != nil {
70 +
		a.Log.Error("create note failed", "err", err)
71 +
		web.RedirectWithError(w, r, "/notes/new", "Failed to create note")
72 +
		return
73 +
	}
74 +
	http.Redirect(w, r, "/notes/"+note.ShortID, http.StatusSeeOther)
75 +
}
76 +
77 +
func (a *App) viewNoteHandler(w http.ResponseWriter, r *http.Request) {
78 +
	shortID := r.PathValue("short_id")
79 +
	note, err := getNoteByShortID(a.DB, shortID)
80 +
	if err != nil {
81 +
		a.Log.Error("get note failed", "err", err)
82 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
83 +
		return
84 +
	}
85 +
	if note == nil {
86 +
		http.Error(w, "Note not found", http.StatusNotFound)
87 +
		return
88 +
	}
89 +
	rendered, err := renderMarkdown(note.Content)
90 +
	if err != nil {
91 +
		a.Log.Error("render markdown failed", "err", err)
92 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
93 +
		return
94 +
	}
95 +
	web.Render(a.Templates, w, "view.html", viewPageData{Note: *note, Rendered: template.HTML(rendered)}, a.Log)
96 +
}
97 +
98 +
func (a *App) editNoteGetHandler(w http.ResponseWriter, r *http.Request) {
99 +
	shortID := r.PathValue("short_id")
100 +
	note, err := getNoteByShortID(a.DB, shortID)
101 +
	if err != nil {
102 +
		a.Log.Error("get note failed", "err", err)
103 +
		http.Error(w, "internal server error", http.StatusInternalServerError)
104 +
		return
105 +
	}
106 +
	if note == nil {
107 +
		http.Error(w, "Note not found", http.StatusNotFound)
108 +
		return
109 +
	}
110 +
	web.Render(a.Templates, w, "edit.html", editPageData{Note: *note, Error: r.URL.Query().Get("error")}, a.Log)
111 +
}
112 +
113 +
func (a *App) updateNoteHandler(w http.ResponseWriter, r *http.Request) {
114 +
	shortID := r.PathValue("short_id")
115 +
	if err := r.ParseForm(); err != nil {
116 +
		web.RedirectWithError(w, r, "/notes/"+shortID+"/edit", "Invalid request")
117 +
		return
118 +
	}
119 +
	title := strings.TrimSpace(r.FormValue("title"))
120 +
	content := r.FormValue("content")
121 +
	if title == "" {
122 +
		web.RedirectWithError(w, r, "/notes/"+shortID+"/edit", "Title is required")
123 +
		return
124 +
	}
125 +
	note, err := updateNoteByShortID(a.DB, shortID, title, content)
126 +
	if err != nil {
127 +
		a.Log.Error("update note failed", "err", err)
128 +
		web.RedirectWithError(w, r, "/notes/"+shortID+"/edit", "Failed to update note")
129 +
		return
130 +
	}
131 +
	if note == nil {
132 +
		http.Error(w, "Note not found", http.StatusNotFound)
133 +
		return
134 +
	}
135 +
	http.Redirect(w, r, "/notes/"+shortID, http.StatusSeeOther)
136 +
}
137 +
138 +
func (a *App) deleteNoteHandler(w http.ResponseWriter, r *http.Request) {
139 +
	shortID := r.PathValue("short_id")
140 +
	if _, err := deleteNoteByShortID(a.DB, shortID); err != nil {
141 +
		a.Log.Error("delete note failed", "err", err)
142 +
	}
143 +
	http.Redirect(w, r, "/", http.StatusSeeOther)
144 +
}
apps/jotts-go/internal/store/store.go (added) +110 −0
1 +
package store
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
9 +
)
10 +
11 +
type Note struct {
12 +
	ID        int64  `json:"id"`
13 +
	ShortID   string `json:"short_id"`
14 +
	Title     string `json:"title"`
15 +
	Content   string `json:"content"`
16 +
	CreatedAt string `json:"created_at"`
17 +
	UpdatedAt string `json:"updated_at"`
18 +
}
19 +
20 +
type NoteInput struct {
21 +
	Title   string `json:"title"`
22 +
	Content string `json:"content"`
23 +
}
24 +
25 +
const noteColumns = `id, short_id, title, content, created_at, updated_at`
26 +
27 +
const schema = `
28 +
CREATE TABLE IF NOT EXISTS notes (
29 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
30 +
    short_id   TEXT NOT NULL UNIQUE,
31 +
    title      TEXT NOT NULL,
32 +
    content    TEXT NOT NULL,
33 +
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
34 +
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
35 +
);
36 +
`
37 +
38 +
func Open(path string) (*sql.DB, error) {
39 +
	return sqlite.Open(path, schema)
40 +
}
41 +
42 +
func scanNote(scanner interface{ Scan(dest ...any) error }) (*Note, error) {
43 +
	var n Note
44 +
	err := scanner.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt)
45 +
	if errors.Is(err, sql.ErrNoRows) {
46 +
		return nil, nil
47 +
	}
48 +
	if err != nil {
49 +
		return nil, err
50 +
	}
51 +
	return &n, nil
52 +
}
53 +
54 +
func Create(db *sql.DB, title, content string) (*Note, error) {
55 +
	shortID, err := auth.GenerateShortID(10)
56 +
	if err != nil {
57 +
		return nil, err
58 +
	}
59 +
	res, err := db.Exec(`INSERT INTO notes (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
60 +
	if err != nil {
61 +
		return nil, err
62 +
	}
63 +
	id, err := res.LastInsertId()
64 +
	if err != nil {
65 +
		return nil, err
66 +
	}
67 +
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE id = ?`, id))
68 +
}
69 +
70 +
func GetByShortID(db *sql.DB, shortID string) (*Note, error) {
71 +
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE short_id = ?`, shortID))
72 +
}
73 +
74 +
func List(db *sql.DB) ([]Note, error) {
75 +
	rows, err := db.Query(`SELECT ` + noteColumns + ` FROM notes ORDER BY id DESC`)
76 +
	if err != nil {
77 +
		return nil, err
78 +
	}
79 +
	defer rows.Close()
80 +
	var out []Note
81 +
	for rows.Next() {
82 +
		var n Note
83 +
		if err := rows.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt); err != nil {
84 +
			return nil, err
85 +
		}
86 +
		out = append(out, n)
87 +
	}
88 +
	return out, rows.Err()
89 +
}
90 +
91 +
func UpdateByShortID(db *sql.DB, shortID, title, content string) (*Note, error) {
92 +
	res, err := db.Exec(`UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE short_id = ?`, title, content, shortID)
93 +
	if err != nil {
94 +
		return nil, err
95 +
	}
96 +
	n, _ := res.RowsAffected()
97 +
	if n == 0 {
98 +
		return nil, nil
99 +
	}
100 +
	return GetByShortID(db, shortID)
101 +
}
102 +
103 +
func DeleteByShortID(db *sql.DB, shortID string) (bool, error) {
104 +
	res, err := db.Exec(`DELETE FROM notes WHERE short_id = ?`, shortID)
105 +
	if err != nil {
106 +
		return false, err
107 +
	}
108 +
	n, _ := res.RowsAffected()
109 +
	return n > 0, nil
110 +
}
apps/jotts-go/main.go (added) +54 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
7 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/tui"
8 +
	"github.com/stevedylandev/andromeda/crates-go/config"
9 +
)
10 +
11 +
func main() {
12 +
	config.LoadDotEnv(".env")
13 +
14 +
	args := os.Args[1:]
15 +
	if len(args) == 0 {
16 +
		runTUI(nil)
17 +
		return
18 +
	}
19 +
20 +
	switch args[0] {
21 +
	case "server":
22 +
		runServer(args[1:])
23 +
	case "tui":
24 +
		runTUI(args[1:])
25 +
	case "auth":
26 +
		runAuth(args[1:])
27 +
	case "-h", "--help", "help":
28 +
		printUsage()
29 +
	default:
30 +
		if _, err := os.Stat(args[0]); err == nil {
31 +
			runUpload(args)
32 +
			return
33 +
		}
34 +
		runTUI(args)
35 +
	}
36 +
}
37 +
38 +
func runTUI(args []string) {
39 +
	if err := tui.Run(tui.ParseArgs(args)); err != nil {
40 +
		fmt.Fprintln(os.Stderr, "tui error:", err)
41 +
		os.Exit(1)
42 +
	}
43 +
}
44 +
45 +
func printUsage() {
46 +
	fmt.Println(`jotts-go — minimal markdown notes
47 +
48 +
usage:
49 +
  jotts-go                            launch TUI (default)
50 +
  jotts-go tui  [--remote URL --api-key KEY]
51 +
  jotts-go server                     run HTTP server
52 +
  jotts-go auth                       configure remote URL + API key
53 +
  jotts-go <file.md>                  upload file as a new note`)
54 +
}
apps/jotts-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.Strikethrough, extension.Table, extension.TaskList),
14 +
	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
15 +
	goldmark.WithRendererOptions(html.WithUnsafe()),
16 +
)
17 +
18 +
func renderMarkdown(source string) (string, error) {
19 +
	var buf bytes.Buffer
20 +
	if err := md.Convert([]byte(source), &buf); err != nil {
21 +
		return "", err
22 +
	}
23 +
	return buf.String(), nil
24 +
}
apps/jotts-go/routes.go (added) +43 −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 +
	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 +
	}
23 +
24 +
	mux.HandleFunc("GET /login", a.loginGetHandler)
25 +
	mux.HandleFunc("POST /login", a.loginPostHandler)
26 +
	mux.HandleFunc("GET /logout", a.logoutHandler)
27 +
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))
35 +
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))
41 +
42 +
	return mux
43 +
}
apps/jotts-go/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

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

Binary file — no preview.

apps/jotts-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/jotts-go/static/styles.css (added) +144 −0
1 +
/* jotts — app-specific styles.
2 +
 * Shared reset / tokens / components come from /assets/darkmatter.css.
3 +
 */
4 +
5 +
.note-list {
6 +
  display: flex;
7 +
  flex-direction: column;
8 +
  width: 100%;
9 +
}
10 +
11 +
.note-item {
12 +
  display: flex;
13 +
  justify-content: space-between;
14 +
  align-items: center;
15 +
  padding: 8px 0;
16 +
  border-bottom: 1px solid #333;
17 +
  text-decoration: none;
18 +
}
19 +
20 +
.note-item:hover {
21 +
  opacity: 0.7;
22 +
}
23 +
24 +
.note-title {
25 +
  font-size: 16px;
26 +
}
27 +
28 +
.note-date {
29 +
  font-size: 12px;
30 +
  opacity: 0.5;
31 +
}
32 +
33 +
/* Note view */
34 +
35 +
.note-header {
36 +
  display: flex;
37 +
  flex-direction: column;
38 +
  gap: 0.25rem;
39 +
}
40 +
41 +
.note-header h1 {
42 +
  font-size: 24px;
43 +
  font-weight: 700;
44 +
  letter-spacing: -0.5px;
45 +
}
46 +
47 +
.note-actions {
48 +
  display: flex;
49 +
  gap: 1.5rem;
50 +
  font-size: 12px;
51 +
}
52 +
53 +
/* Markdown rendered content */
54 +
55 +
.markdown-body {
56 +
  width: 100%;
57 +
  line-height: 1.6;
58 +
}
59 +
60 +
.markdown-body h1,
61 +
.markdown-body h2,
62 +
.markdown-body h3,
63 +
.markdown-body h4,
64 +
.markdown-body h5,
65 +
.markdown-body h6 {
66 +
  margin-top: 1.5rem;
67 +
  margin-bottom: 0.5rem;
68 +
  font-weight: 700;
69 +
}
70 +
71 +
.markdown-body h1 { font-size: 18px; }
72 +
.markdown-body h2 { font-size: 16px; }
73 +
.markdown-body h3 { font-size: 15px; }
74 +
.markdown-body h4,
75 +
.markdown-body h5,
76 +
.markdown-body h6 { font-size: 14px; }
77 +
78 +
.markdown-body p {
79 +
  margin-bottom: 0.75rem;
80 +
}
81 +
82 +
.markdown-body ul,
83 +
.markdown-body ol {
84 +
  margin-left: 1.5rem;
85 +
  margin-bottom: 0.75rem;
86 +
}
87 +
88 +
.markdown-body li {
89 +
  margin-bottom: 0.25rem;
90 +
}
91 +
92 +
.markdown-body pre {
93 +
  margin-bottom: 0.75rem;
94 +
}
95 +
96 +
.markdown-body blockquote {
97 +
  border-left: 2px solid #555;
98 +
  padding-left: 12px;
99 +
  opacity: 0.7;
100 +
  margin-bottom: 0.75rem;
101 +
}
102 +
103 +
.markdown-body table {
104 +
  margin-bottom: 0.75rem;
105 +
}
106 +
107 +
.markdown-body th,
108 +
.markdown-body td {
109 +
  border: 1px solid #333;
110 +
  padding: 6px;
111 +
  text-align: left;
112 +
}
113 +
114 +
.markdown-body th {
115 +
  font-weight: 700;
116 +
}
117 +
118 +
.markdown-body hr {
119 +
  border: none;
120 +
  border-top: 1px solid #333;
121 +
  margin: 1rem 0;
122 +
}
123 +
124 +
.markdown-body a {
125 +
  text-decoration: underline;
126 +
}
127 +
128 +
.markdown-body img {
129 +
  max-width: 100%;
130 +
}
131 +
132 +
.markdown-body li:has(> input[type="checkbox"]) {
133 +
  list-style: none;
134 +
  margin-left: -1.5rem;
135 +
}
136 +
137 +
.markdown-body input[type="checkbox"] {
138 +
  width: 14px;
139 +
  height: 14px;
140 +
  margin-right: 6px;
141 +
  vertical-align: middle;
142 +
  position: relative;
143 +
  top: -1px;
144 +
}
apps/jotts-go/templates/edit.html (added) +32 −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 +
    <title>Jotts — {{.Note.Title}}</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 name="theme-color" content="#121113" />
13 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
14 +
    <link rel="stylesheet" href="/static/styles.css">
15 +
  </head>
16 +
  <body>
17 +
    <header class="header">
18 +
      <a href="/" class="logo">jotts</a>
19 +
      <nav class="links"><a href="/notes/new">new</a></nav>
20 +
    </header>
21 +
    <main>
22 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
      <form method="POST" action="/notes/{{.Note.ShortID}}" class="form">
24 +
        <label for="title">title</label>
25 +
        <input type="text" id="title" name="title" value="{{.Note.Title}}" required>
26 +
        <label for="content">content</label>
27 +
        <textarea id="content" name="content">{{.Note.Content}}</textarea>
28 +
        <button type="submit">save</button>
29 +
      </form>
30 +
    </main>
31 +
  </body>
32 +
</html>
apps/jotts-go/templates/index.html (added) +44 −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 +
    <title>Jotts</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="Jotts">
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 +
  </head>
19 +
  <body>
20 +
    <header class="header">
21 +
      <a href="/" class="logo">jotts</a>
22 +
      <nav class="links">
23 +
        <a href="/notes/new">new</a>
24 +
      </nav>
25 +
    </header>
26 +
    <main>
27 +
      {{if not .Notes}}<p class="empty">no notes yet</p>{{end}}
28 +
      <div class="note-list">
29 +
        {{range .Notes}}
30 +
          <a href="/notes/{{.ShortID}}" class="note-item">
31 +
            <span class="note-title">{{.Title}}</span>
32 +
            <time class="note-date" datetime="{{.UpdatedAt}}Z">{{.UpdatedAt}}</time>
33 +
          </a>
34 +
        {{end}}
35 +
      </div>
36 +
    </main>
37 +
    <script>
38 +
      document.querySelectorAll("time.note-date").forEach(el => {
39 +
        const d = new Date(el.getAttribute("datetime"));
40 +
        if (!isNaN(d)) { el.textContent = d.toLocaleString(); }
41 +
      });
42 +
    </script>
43 +
  </body>
44 +
</html>
apps/jotts-go/templates/login.html (added) +32 −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 +
    <title>Jotts</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="Jotts">
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 +
  </head>
19 +
  <body>
20 +
    <header class="header">
21 +
      <span class="logo">JOTTS</span>
22 +
    </header>
23 +
    <main>
24 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
25 +
      <form method="POST" action="/login" class="form">
26 +
        <label for="password">password</label>
27 +
        <input type="password" id="password" name="password" autofocus required>
28 +
        <button type="submit">login</button>
29 +
      </form>
30 +
    </main>
31 +
  </body>
32 +
</html>
apps/jotts-go/templates/new.html (added) +32 −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 +
    <title>Jotts — new</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 name="theme-color" content="#121113" />
13 +
    <link rel="stylesheet" href="/assets/darkmatter.css">
14 +
    <link rel="stylesheet" href="/static/styles.css">
15 +
  </head>
16 +
  <body>
17 +
    <header class="header">
18 +
      <a href="/" class="logo">jotts</a>
19 +
      <nav class="links"><a href="/notes/new">new</a></nav>
20 +
    </header>
21 +
    <main>
22 +
      {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
      <form method="POST" action="/notes" class="form">
24 +
        <label for="title">title</label>
25 +
        <input type="text" id="title" name="title" autofocus required>
26 +
        <label for="content">content</label>
27 +
        <textarea id="content" name="content" placeholder="write markdown here..."></textarea>
28 +
        <button type="submit">save</button>
29 +
      </form>
30 +
    </main>
31 +
  </body>
32 +
</html>
apps/jotts-go/templates/view.html (added) +54 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>Jotts — {{.Note.Title}}</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="Jotts">
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 +
  </head>
19 +
  <body>
20 +
    <header class="header">
21 +
      <a href="/" class="logo">jotts</a>
22 +
      <nav class="links"><a href="/notes/new">new</a></nav>
23 +
    </header>
24 +
    <main>
25 +
      <div class="note-header">
26 +
        <h1>{{.Note.Title}}</h1>
27 +
        <time class="note-date" datetime="{{.Note.UpdatedAt}}Z">{{.Note.UpdatedAt}}</time>
28 +
      </div>
29 +
      <div class="note-actions">
30 +
        <a href="/notes/{{.Note.ShortID}}/edit">edit</a>
31 +
        <button type="button" class="link-button" id="copy-md-btn" onclick="copyMarkdown()">copy</button>
32 +
        <form method="POST" action="/notes/{{.Note.ShortID}}/delete" class="inline-form">
33 +
          <button type="submit" class="link-button" onclick="return confirm('delete this note?')">delete</button>
34 +
        </form>
35 +
      </div>
36 +
      <template id="raw-md">{{.Note.Content}}</template>
37 +
      <article class="markdown-body">{{.Rendered}}</article>
38 +
    </main>
39 +
    <script>
40 +
      function copyMarkdown() {
41 +
        const md = document.getElementById("raw-md").content.textContent;
42 +
        const btn = document.getElementById("copy-md-btn");
43 +
        navigator.clipboard.writeText(md).then(() => {
44 +
          btn.textContent = "copied!";
45 +
          setTimeout(() => { btn.textContent = "copy"; }, 1500);
46 +
        });
47 +
      }
48 +
      document.querySelectorAll("time.note-date").forEach(el => {
49 +
        const d = new Date(el.getAttribute("datetime"));
50 +
        if (!isNaN(d)) { el.textContent = d.toLocaleString(); }
51 +
      });
52 +
    </script>
53 +
  </body>
54 +
</html>
apps/jotts-go/tui/backend.go (added) +210 −0
1 +
package tui
2 +
3 +
import (
4 +
	"bytes"
5 +
	"database/sql"
6 +
	"encoding/json"
7 +
	"fmt"
8 +
	"io"
9 +
	"net/http"
10 +
	"os"
11 +
	"strings"
12 +
	"time"
13 +
14 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/internal/store"
15 +
	"github.com/stevedylandev/andromeda/crates-go/config"
16 +
)
17 +
18 +
type Note = store.Note
19 +
20 +
type Backend interface {
21 +
	List() ([]Note, error)
22 +
	Get(shortID string) (*Note, error)
23 +
	Create(title, content string) (*Note, error)
24 +
	Update(shortID, title, content string) (*Note, error)
25 +
	Delete(shortID string) (bool, error)
26 +
	RemoteURL() string
27 +
	Close() error
28 +
}
29 +
30 +
type LocalBackend struct {
31 +
	DB *sql.DB
32 +
}
33 +
34 +
func (b *LocalBackend) List() ([]Note, error)               { return store.List(b.DB) }
35 +
func (b *LocalBackend) Get(s string) (*Note, error)         { return store.GetByShortID(b.DB, s) }
36 +
func (b *LocalBackend) Create(t, c string) (*Note, error)   { return store.Create(b.DB, t, c) }
37 +
func (b *LocalBackend) Update(s, t, c string) (*Note, error) {
38 +
	return store.UpdateByShortID(b.DB, s, t, c)
39 +
}
40 +
func (b *LocalBackend) Delete(s string) (bool, error) { return store.DeleteByShortID(b.DB, s) }
41 +
func (b *LocalBackend) RemoteURL() string             { return "" }
42 +
func (b *LocalBackend) Close() error                  { return b.DB.Close() }
43 +
44 +
type RemoteBackend struct {
45 +
	BaseURL string
46 +
	APIKey  string
47 +
	Client  *http.Client
48 +
}
49 +
50 +
func (r *RemoteBackend) RemoteURL() string { return r.BaseURL }
51 +
func (r *RemoteBackend) Close() error      { return nil }
52 +
53 +
func (r *RemoteBackend) do(method, path string, body any, out any) error {
54 +
	var reader io.Reader
55 +
	if body != nil {
56 +
		buf, err := json.Marshal(body)
57 +
		if err != nil {
58 +
			return err
59 +
		}
60 +
		reader = bytes.NewReader(buf)
61 +
	}
62 +
	req, err := http.NewRequest(method, strings.TrimRight(r.BaseURL, "/")+path, reader)
63 +
	if err != nil {
64 +
		return err
65 +
	}
66 +
	if body != nil {
67 +
		req.Header.Set("Content-Type", "application/json")
68 +
	}
69 +
	if r.APIKey != "" {
70 +
		req.Header.Set("x-api-key", r.APIKey)
71 +
	}
72 +
	resp, err := r.Client.Do(req)
73 +
	if err != nil {
74 +
		return err
75 +
	}
76 +
	defer resp.Body.Close()
77 +
	if resp.StatusCode == http.StatusNotFound {
78 +
		return errNotFound
79 +
	}
80 +
	if resp.StatusCode >= 400 {
81 +
		b, _ := io.ReadAll(resp.Body)
82 +
		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
83 +
	}
84 +
	if out == nil || resp.StatusCode == http.StatusNoContent {
85 +
		return nil
86 +
	}
87 +
	return json.NewDecoder(resp.Body).Decode(out)
88 +
}
89 +
90 +
var errNotFound = fmt.Errorf("not found")
91 +
92 +
func (r *RemoteBackend) List() ([]Note, error) {
93 +
	var out []Note
94 +
	if err := r.do("GET", "/api/notes", nil, &out); err != nil {
95 +
		return nil, err
96 +
	}
97 +
	return out, nil
98 +
}
99 +
100 +
func (r *RemoteBackend) Get(shortID string) (*Note, error) {
101 +
	var n Note
102 +
	if err := r.do("GET", "/api/notes/"+shortID, nil, &n); err != nil {
103 +
		if err == errNotFound {
104 +
			return nil, nil
105 +
		}
106 +
		return nil, err
107 +
	}
108 +
	return &n, nil
109 +
}
110 +
111 +
func (r *RemoteBackend) Create(title, content string) (*Note, error) {
112 +
	var n Note
113 +
	if err := r.do("POST", "/api/notes", store.NoteInput{Title: title, Content: content}, &n); err != nil {
114 +
		return nil, err
115 +
	}
116 +
	return &n, nil
117 +
}
118 +
119 +
func (r *RemoteBackend) Update(shortID, title, content string) (*Note, error) {
120 +
	var n Note
121 +
	if err := r.do("PUT", "/api/notes/"+shortID, store.NoteInput{Title: title, Content: content}, &n); err != nil {
122 +
		if err == errNotFound {
123 +
			return nil, nil
124 +
		}
125 +
		return nil, err
126 +
	}
127 +
	return &n, nil
128 +
}
129 +
130 +
func (r *RemoteBackend) Delete(shortID string) (bool, error) {
131 +
	if err := r.do("DELETE", "/api/notes/"+shortID, nil, nil); err != nil {
132 +
		if err == errNotFound {
133 +
			return false, nil
134 +
		}
135 +
		return false, err
136 +
	}
137 +
	return true, nil
138 +
}
139 +
140 +
type Options struct {
141 +
	RemoteURL string
142 +
	APIKey    string
143 +
	DBPath    string
144 +
}
145 +
146 +
func ParseArgs(args []string) Options {
147 +
	opts := Options{}
148 +
	for i := 0; i < len(args); i++ {
149 +
		a := args[i]
150 +
		switch {
151 +
		case a == "--remote" && i+1 < len(args):
152 +
			opts.RemoteURL = args[i+1]
153 +
			i++
154 +
		case strings.HasPrefix(a, "--remote="):
155 +
			opts.RemoteURL = strings.TrimPrefix(a, "--remote=")
156 +
		case a == "--api-key" && i+1 < len(args):
157 +
			opts.APIKey = args[i+1]
158 +
			i++
159 +
		case strings.HasPrefix(a, "--api-key="):
160 +
			opts.APIKey = strings.TrimPrefix(a, "--api-key=")
161 +
		case a == "--db" && i+1 < len(args):
162 +
			opts.DBPath = args[i+1]
163 +
			i++
164 +
		}
165 +
	}
166 +
	return opts
167 +
}
168 +
169 +
func ResolveBackend(opts Options) (Backend, error) {
170 +
	cfg, _ := LoadConfig()
171 +
172 +
	remoteURL := opts.RemoteURL
173 +
	if remoteURL == "" {
174 +
		remoteURL = os.Getenv("JOTTS_REMOTE_URL")
175 +
	}
176 +
	apiKey := opts.APIKey
177 +
	if apiKey == "" {
178 +
		apiKey = os.Getenv("JOTTS_API_KEY")
179 +
	}
180 +
	if apiKey == "" {
181 +
		apiKey = cfg.APIKey
182 +
	}
183 +
184 +
	dbPath := opts.DBPath
185 +
	if dbPath == "" {
186 +
		dbPath = config.Getenv("JOTTS_DB_PATH", "jotts.sqlite")
187 +
	}
188 +
189 +
	useRemote := remoteURL != ""
190 +
	if !useRemote {
191 +
		if _, err := os.Stat(dbPath); err != nil && cfg.RemoteURL != "" {
192 +
			remoteURL = cfg.RemoteURL
193 +
			useRemote = true
194 +
		}
195 +
	}
196 +
197 +
	if useRemote {
198 +
		return &RemoteBackend{
199 +
			BaseURL: remoteURL,
200 +
			APIKey:  apiKey,
201 +
			Client:  &http.Client{Timeout: 15 * time.Second},
202 +
		}, nil
203 +
	}
204 +
205 +
	db, err := store.Open(dbPath)
206 +
	if err != nil {
207 +
		return nil, err
208 +
	}
209 +
	return &LocalBackend{DB: db}, nil
210 +
}
apps/jotts-go/tui/config.go (added) +56 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
7 +
	"github.com/BurntSushi/toml"
8 +
)
9 +
10 +
type Config struct {
11 +
	RemoteURL string `toml:"remote_url"`
12 +
	APIKey    string `toml:"api_key"`
13 +
}
14 +
15 +
func ConfigPath() (string, error) {
16 +
	dir, err := os.UserConfigDir()
17 +
	if err != nil {
18 +
		return "", err
19 +
	}
20 +
	return filepath.Join(dir, "jotts", "config.toml"), nil
21 +
}
22 +
23 +
func LoadConfig() (Config, error) {
24 +
	var cfg Config
25 +
	path, err := ConfigPath()
26 +
	if err != nil {
27 +
		return cfg, err
28 +
	}
29 +
	data, err := os.ReadFile(path)
30 +
	if err != nil {
31 +
		if os.IsNotExist(err) {
32 +
			return cfg, nil
33 +
		}
34 +
		return cfg, err
35 +
	}
36 +
	if err := toml.Unmarshal(data, &cfg); err != nil {
37 +
		return cfg, err
38 +
	}
39 +
	return cfg, nil
40 +
}
41 +
42 +
func SaveConfig(cfg Config) error {
43 +
	path, err := ConfigPath()
44 +
	if err != nil {
45 +
		return err
46 +
	}
47 +
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 +
		return err
49 +
	}
50 +
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
51 +
	if err != nil {
52 +
		return err
53 +
	}
54 +
	defer f.Close()
55 +
	return toml.NewEncoder(f).Encode(cfg)
56 +
}
apps/jotts-go/tui/editor.go (added) +55 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"os/exec"
7 +
	"path/filepath"
8 +
	"runtime"
9 +
10 +
	tea "github.com/charmbracelet/bubbletea"
11 +
)
12 +
13 +
func openExternalEditor(shortID, content string) tea.Cmd {
14 +
	editor := os.Getenv("EDITOR")
15 +
	if editor == "" {
16 +
		return func() tea.Msg {
17 +
			return statusMsg{text: "$EDITOR not set", ok: false}
18 +
		}
19 +
	}
20 +
21 +
	tmp := filepath.Join(os.TempDir(), fmt.Sprintf("jotts-%s.md", shortID))
22 +
	if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
23 +
		return func() tea.Msg {
24 +
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
25 +
		}
26 +
	}
27 +
28 +
	cmd := exec.Command(editor, tmp)
29 +
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
30 +
		defer os.Remove(tmp)
31 +
		if err != nil {
32 +
			return editorFinishedMsg{shortID: shortID, err: err}
33 +
		}
34 +
		b, rerr := os.ReadFile(tmp)
35 +
		if rerr != nil {
36 +
			return editorFinishedMsg{shortID: shortID, err: rerr}
37 +
		}
38 +
		return editorFinishedMsg{shortID: shortID, content: string(b)}
39 +
	})
40 +
}
41 +
42 +
func openURL(url string) error {
43 +
	var cmd *exec.Cmd
44 +
	switch runtime.GOOS {
45 +
	case "linux":
46 +
		cmd = exec.Command("xdg-open", url)
47 +
	case "darwin":
48 +
		cmd = exec.Command("open", url)
49 +
	case "windows":
50 +
		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
51 +
	default:
52 +
		return fmt.Errorf("unsupported platform %s", runtime.GOOS)
53 +
	}
54 +
	return cmd.Start()
55 +
}
apps/jotts-go/tui/keys.go (added) +63 −0
1 +
package tui
2 +
3 +
import "github.com/charmbracelet/bubbles/key"
4 +
5 +
type keyMap struct {
6 +
	Up          key.Binding
7 +
	Down        key.Binding
8 +
	Open        key.Binding
9 +
	Back        key.Binding
10 +
	Quit        key.Binding
11 +
	Create      key.Binding
12 +
	Edit        key.Binding
13 +
	ExtEdit     key.Binding
14 +
	Delete      key.Binding
15 +
	Copy        key.Binding
16 +
	CopyLink    key.Binding
17 +
	OpenBrowser key.Binding
18 +
	Search      key.Binding
19 +
	Refresh     key.Binding
20 +
	Help        key.Binding
21 +
	Save        key.Binding
22 +
	ToggleWrap  key.Binding
23 +
	SwitchField key.Binding
24 +
	Cancel      key.Binding
25 +
}
26 +
27 +
func defaultKeys() keyMap {
28 +
	return keyMap{
29 +
		Up:          key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
30 +
		Down:        key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
31 +
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
32 +
		Back:        key.NewBinding(key.WithKeys("h", "esc", " "), key.WithHelp("h/␣", "back")),
33 +
		Quit:        key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
34 +
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
35 +
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
36 +
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
37 +
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
38 +
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
39 +
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
40 +
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
41 +
		Search:      key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
42 +
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
43 +
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
44 +
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
45 +
		ToggleWrap:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
46 +
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
47 +
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
48 +
	}
49 +
}
50 +
51 +
func (k keyMap) ShortHelp() []key.Binding {
52 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit}
53 +
}
54 +
55 +
func (k keyMap) FullHelp() [][]key.Binding {
56 +
	return [][]key.Binding{
57 +
		{k.Up, k.Down, k.Open, k.Back},
58 +
		{k.Create, k.Edit, k.ExtEdit, k.Delete},
59 +
		{k.Copy, k.CopyLink, k.OpenBrowser, k.Search},
60 +
		{k.Refresh, k.Help, k.Save, k.ToggleWrap},
61 +
		{k.SwitchField, k.Cancel, k.Quit},
62 +
	}
63 +
}
apps/jotts-go/tui/messages.go (added) +29 −0
1 +
package tui
2 +
3 +
type notesLoadedMsg struct {
4 +
	notes []Note
5 +
	err   error
6 +
}
7 +
8 +
type noteSavedMsg struct {
9 +
	note *Note
10 +
	err  error
11 +
}
12 +
13 +
type noteDeletedMsg struct {
14 +
	shortID string
15 +
	err     error
16 +
}
17 +
18 +
type editorFinishedMsg struct {
19 +
	shortID string
20 +
	content string
21 +
	err     error
22 +
}
23 +
24 +
type statusMsg struct {
25 +
	text string
26 +
	ok   bool
27 +
}
28 +
29 +
type clearStatusMsg struct{}
apps/jotts-go/tui/model.go (added) +140 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
	"time"
6 +
7 +
	"github.com/charmbracelet/bubbles/help"
8 +
	"github.com/charmbracelet/bubbles/textarea"
9 +
	"github.com/charmbracelet/bubbles/textinput"
10 +
	"github.com/charmbracelet/bubbles/viewport"
11 +
	tea "github.com/charmbracelet/bubbletea"
12 +
)
13 +
14 +
type Focus int
15 +
16 +
const (
17 +
	FocusList Focus = iota
18 +
	FocusContent
19 +
	FocusCreateTitle
20 +
	FocusCreateContent
21 +
	FocusEditTitle
22 +
	FocusEditContent
23 +
	FocusSearch
24 +
)
25 +
26 +
type Model struct {
27 +
	backend     Backend
28 +
	isRemote    bool
29 +
30 +
	notes    []Note
31 +
	filtered []int
32 +
	cursor   int
33 +
34 +
	focus         Focus
35 +
	showHelp      bool
36 +
	confirmDelete bool
37 +
38 +
	titleInput  textinput.Model
39 +
	contentArea textarea.Model
40 +
	searchInput textinput.Model
41 +
	contentVP   viewport.Model
42 +
	help        help.Model
43 +
	keys        keyMap
44 +
45 +
	renderer *mdRenderer
46 +
	wrap     bool
47 +
48 +
	editShortID string
49 +
50 +
	status      string
51 +
	statusOK    bool
52 +
	statusUntil time.Time
53 +
54 +
	width, height int
55 +
	ready         bool
56 +
	loading       bool
57 +
	err           error
58 +
}
59 +
60 +
func newModel(backend Backend) Model {
61 +
	ti := textinput.New()
62 +
	ti.Placeholder = "Title"
63 +
	ti.Prompt = ""
64 +
	ti.CharLimit = 200
65 +
66 +
	ta := textarea.New()
67 +
	ta.Placeholder = "Write markdown..."
68 +
	ta.ShowLineNumbers = false
69 +
	ta.Prompt = ""
70 +
71 +
	si := textinput.New()
72 +
	si.Placeholder = "search titles"
73 +
	si.Prompt = "/ "
74 +
75 +
	vp := viewport.New(0, 0)
76 +
77 +
	return Model{
78 +
		backend:     backend,
79 +
		isRemote:    backend.RemoteURL() != "",
80 +
		focus:       FocusList,
81 +
		titleInput:  ti,
82 +
		contentArea: ta,
83 +
		searchInput: si,
84 +
		contentVP:   vp,
85 +
		help:        help.New(),
86 +
		keys:        defaultKeys(),
87 +
		wrap:        true,
88 +
	}
89 +
}
90 +
91 +
func (m Model) Init() tea.Cmd {
92 +
	return loadNotesCmd(m.backend)
93 +
}
94 +
95 +
func (m *Model) visibleNotes() []Note {
96 +
	if m.filtered == nil {
97 +
		return m.notes
98 +
	}
99 +
	out := make([]Note, 0, len(m.filtered))
100 +
	for _, i := range m.filtered {
101 +
		out = append(out, m.notes[i])
102 +
	}
103 +
	return out
104 +
}
105 +
106 +
func (m *Model) currentNote() *Note {
107 +
	notes := m.visibleNotes()
108 +
	if m.cursor < 0 || m.cursor >= len(notes) {
109 +
		return nil
110 +
	}
111 +
	return &notes[m.cursor]
112 +
}
113 +
114 +
func (m *Model) applyFilter(q string) {
115 +
	q = strings.TrimSpace(strings.ToLower(q))
116 +
	if q == "" {
117 +
		m.filtered = nil
118 +
		if m.cursor >= len(m.notes) {
119 +
			m.cursor = 0
120 +
		}
121 +
		return
122 +
	}
123 +
	idx := []int{}
124 +
	for i, n := range m.notes {
125 +
		if strings.Contains(strings.ToLower(n.Title), q) {
126 +
			idx = append(idx, i)
127 +
		}
128 +
	}
129 +
	m.filtered = idx
130 +
	if m.cursor >= len(idx) {
131 +
		m.cursor = 0
132 +
	}
133 +
}
134 +
135 +
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
136 +
	m.status = text
137 +
	m.statusOK = ok
138 +
	m.statusUntil = time.Now().Add(2 * time.Second)
139 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
140 +
}
apps/jotts-go/tui/render_md.go (added) +56 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
6 +
	"github.com/charmbracelet/glamour"
7 +
)
8 +
9 +
type mdRenderer struct {
10 +
	r     *glamour.TermRenderer
11 +
	width int
12 +
	cache map[string]string
13 +
}
14 +
15 +
func newRenderer(width int) *mdRenderer {
16 +
	if width < 20 {
17 +
		width = 80
18 +
	}
19 +
	r, _ := glamour.NewTermRenderer(
20 +
		glamour.WithAutoStyle(),
21 +
		glamour.WithWordWrap(width-2),
22 +
	)
23 +
	return &mdRenderer{r: r, width: width, cache: map[string]string{}}
24 +
}
25 +
26 +
func (m *mdRenderer) resize(width int) {
27 +
	if width == m.width || width < 20 {
28 +
		return
29 +
	}
30 +
	r, _ := glamour.NewTermRenderer(
31 +
		glamour.WithAutoStyle(),
32 +
		glamour.WithWordWrap(width-2),
33 +
	)
34 +
	m.r = r
35 +
	m.width = width
36 +
	m.cache = map[string]string{}
37 +
}
38 +
39 +
func (m *mdRenderer) render(key, body string) string {
40 +
	if m.r == nil {
41 +
		return body
42 +
	}
43 +
	if v, ok := m.cache[key]; ok {
44 +
		return v
45 +
	}
46 +
	out, err := m.r.Render(body)
47 +
	if err != nil {
48 +
		out = fmt.Sprintf("render error: %v\n\n%s", err, body)
49 +
	}
50 +
	m.cache[key] = out
51 +
	return out
52 +
}
53 +
54 +
func (m *mdRenderer) invalidate(key string) {
55 +
	delete(m.cache, key)
56 +
}
apps/jotts-go/tui/tui.go (added) +17 −0
1 +
package tui
2 +
3 +
import (
4 +
	tea "github.com/charmbracelet/bubbletea"
5 +
)
6 +
7 +
func Run(opts Options) error {
8 +
	backend, err := ResolveBackend(opts)
9 +
	if err != nil {
10 +
		return err
11 +
	}
12 +
	defer backend.Close()
13 +
14 +
	p := tea.NewProgram(newModel(backend), tea.WithAltScreen())
15 +
	_, err = p.Run()
16 +
	return err
17 +
}
apps/jotts-go/tui/update.go (added) +413 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	"github.com/atotto/clipboard"
7 +
	"github.com/charmbracelet/bubbles/key"
8 +
	tea "github.com/charmbracelet/bubbletea"
9 +
)
10 +
11 +
func loadNotesCmd(b Backend) tea.Cmd {
12 +
	return func() tea.Msg {
13 +
		notes, err := b.List()
14 +
		return notesLoadedMsg{notes: notes, err: err}
15 +
	}
16 +
}
17 +
18 +
func saveNoteCmd(b Backend, shortID, title, content string) tea.Cmd {
19 +
	return func() tea.Msg {
20 +
		var (
21 +
			note *Note
22 +
			err  error
23 +
		)
24 +
		if shortID == "" {
25 +
			note, err = b.Create(title, content)
26 +
		} else {
27 +
			note, err = b.Update(shortID, title, content)
28 +
		}
29 +
		return noteSavedMsg{note: note, err: err}
30 +
	}
31 +
}
32 +
33 +
func deleteNoteCmd(b Backend, shortID string) tea.Cmd {
34 +
	return func() tea.Msg {
35 +
		_, err := b.Delete(shortID)
36 +
		return noteDeletedMsg{shortID: shortID, err: err}
37 +
	}
38 +
}
39 +
40 +
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41 +
	switch msg := msg.(type) {
42 +
43 +
	case tea.WindowSizeMsg:
44 +
		m.width, m.height = msg.Width, msg.Height
45 +
		m.ready = true
46 +
		m.resizePanes()
47 +
		return m, nil
48 +
49 +
	case notesLoadedMsg:
50 +
		m.loading = false
51 +
		if msg.err != nil {
52 +
			cmd := m.setStatus("load: "+msg.err.Error(), false)
53 +
			return m, cmd
54 +
		}
55 +
		m.notes = msg.notes
56 +
		m.applyFilter(m.searchInput.Value())
57 +
		m.refreshPreview()
58 +
		return m, nil
59 +
60 +
	case noteSavedMsg:
61 +
		if msg.err != nil {
62 +
			return m, m.setStatus("save: "+msg.err.Error(), false)
63 +
		}
64 +
		m.focus = FocusList
65 +
		m.titleInput.Reset()
66 +
		m.contentArea.Reset()
67 +
		m.editShortID = ""
68 +
		cmds := []tea.Cmd{loadNotesCmd(m.backend), m.setStatus("saved", true)}
69 +
		return m, tea.Batch(cmds...)
70 +
71 +
	case noteDeletedMsg:
72 +
		if msg.err != nil {
73 +
			return m, m.setStatus("delete: "+msg.err.Error(), false)
74 +
		}
75 +
		if m.renderer != nil {
76 +
			m.renderer.invalidate(msg.shortID)
77 +
		}
78 +
		return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("deleted", true))
79 +
80 +
	case editorFinishedMsg:
81 +
		if msg.err != nil {
82 +
			return m, m.setStatus("editor: "+msg.err.Error(), false)
83 +
		}
84 +
		if msg.shortID == "" {
85 +
			m.contentArea.SetValue(msg.content)
86 +
			return m, nil
87 +
		}
88 +
		var orig *Note
89 +
		for i := range m.notes {
90 +
			if m.notes[i].ShortID == msg.shortID {
91 +
				orig = &m.notes[i]
92 +
				break
93 +
			}
94 +
		}
95 +
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.content, "\n") {
96 +
			return m, nil
97 +
		}
98 +
		return m, saveNoteCmd(m.backend, msg.shortID, orig.Title, msg.content)
99 +
100 +
	case statusMsg:
101 +
		return m, m.setStatus(msg.text, msg.ok)
102 +
103 +
	case clearStatusMsg:
104 +
		m.status = ""
105 +
		return m, nil
106 +
107 +
	case tea.KeyMsg:
108 +
		return m.handleKey(msg)
109 +
	}
110 +
111 +
	return m, nil
112 +
}
113 +
114 +
func (m *Model) resizePanes() {
115 +
	if !m.ready {
116 +
		return
117 +
	}
118 +
	listW := m.width * 30 / 100
119 +
	if listW < 24 {
120 +
		listW = 24
121 +
	}
122 +
	contentW := m.width - listW - 2
123 +
	if contentW < 20 {
124 +
		contentW = 20
125 +
	}
126 +
	bodyH := m.height - 2
127 +
	if bodyH < 5 {
128 +
		bodyH = 5
129 +
	}
130 +
131 +
	m.contentVP.Width = contentW - 2
132 +
	m.contentVP.Height = bodyH - 2
133 +
134 +
	m.titleInput.Width = contentW - 4
135 +
	m.contentArea.SetWidth(contentW - 2)
136 +
	m.contentArea.SetHeight(bodyH - 5)
137 +
138 +
	m.searchInput.Width = listW - 4
139 +
140 +
	if m.renderer == nil {
141 +
		m.renderer = newRenderer(contentW)
142 +
	} else {
143 +
		m.renderer.resize(contentW)
144 +
	}
145 +
	m.refreshPreview()
146 +
}
147 +
148 +
func (m *Model) refreshPreview() {
149 +
	if m.renderer == nil {
150 +
		return
151 +
	}
152 +
	n := m.currentNote()
153 +
	if n == nil {
154 +
		m.contentVP.SetContent("")
155 +
		return
156 +
	}
157 +
	body := n.Content
158 +
	if !m.wrap {
159 +
		// raw view: no rendering
160 +
		m.contentVP.SetContent(body)
161 +
		return
162 +
	}
163 +
	m.contentVP.SetContent(m.renderer.render(n.ShortID, body))
164 +
}
165 +
166 +
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
167 +
	if m.confirmDelete {
168 +
		switch msg.String() {
169 +
		case "y", "Y":
170 +
			n := m.currentNote()
171 +
			m.confirmDelete = false
172 +
			if n == nil {
173 +
				return m, nil
174 +
			}
175 +
			return m, deleteNoteCmd(m.backend, n.ShortID)
176 +
		case "n", "N", "esc", "q":
177 +
			m.confirmDelete = false
178 +
			return m, nil
179 +
		}
180 +
		return m, nil
181 +
	}
182 +
183 +
	if m.showHelp {
184 +
		if key.Matches(msg, m.keys.Help) || msg.String() == "esc" || msg.String() == "q" {
185 +
			m.showHelp = false
186 +
		}
187 +
		return m, nil
188 +
	}
189 +
190 +
	switch m.focus {
191 +
	case FocusList:
192 +
		return m.keyList(msg)
193 +
	case FocusContent:
194 +
		return m.keyContent(msg)
195 +
	case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent:
196 +
		return m.keyForm(msg)
197 +
	case FocusSearch:
198 +
		return m.keySearch(msg)
199 +
	}
200 +
	return m, nil
201 +
}
202 +
203 +
func (m Model) keyList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
204 +
	notes := m.visibleNotes()
205 +
	switch {
206 +
	case key.Matches(msg, m.keys.Quit):
207 +
		return m, tea.Quit
208 +
	case key.Matches(msg, m.keys.Down):
209 +
		if m.cursor < len(notes)-1 {
210 +
			m.cursor++
211 +
			m.refreshPreview()
212 +
		}
213 +
	case key.Matches(msg, m.keys.Up):
214 +
		if m.cursor > 0 {
215 +
			m.cursor--
216 +
			m.refreshPreview()
217 +
		}
218 +
	case key.Matches(msg, m.keys.Open):
219 +
		if len(notes) > 0 {
220 +
			m.focus = FocusContent
221 +
			m.contentVP.GotoTop()
222 +
		}
223 +
	case key.Matches(msg, m.keys.Create):
224 +
		m.focus = FocusCreateTitle
225 +
		m.editShortID = ""
226 +
		m.titleInput.SetValue("")
227 +
		m.contentArea.SetValue("")
228 +
		m.titleInput.Focus()
229 +
		m.contentArea.Blur()
230 +
	case key.Matches(msg, m.keys.Edit):
231 +
		n := m.currentNote()
232 +
		if n != nil {
233 +
			m.focus = FocusEditTitle
234 +
			m.editShortID = n.ShortID
235 +
			m.titleInput.SetValue(n.Title)
236 +
			m.contentArea.SetValue(n.Content)
237 +
			m.titleInput.Focus()
238 +
			m.contentArea.Blur()
239 +
		}
240 +
	case key.Matches(msg, m.keys.ExtEdit):
241 +
		n := m.currentNote()
242 +
		if n != nil {
243 +
			return m, openExternalEditor(n.ShortID, n.Content)
244 +
		}
245 +
	case key.Matches(msg, m.keys.Delete):
246 +
		if m.currentNote() != nil {
247 +
			m.confirmDelete = true
248 +
		}
249 +
	case key.Matches(msg, m.keys.Copy):
250 +
		n := m.currentNote()
251 +
		if n != nil {
252 +
			if err := clipboard.WriteAll(n.Content); err != nil {
253 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
254 +
			}
255 +
			return m, m.setStatus("copied text", true)
256 +
		}
257 +
	case key.Matches(msg, m.keys.CopyLink):
258 +
		n := m.currentNote()
259 +
		if n != nil && m.isRemote {
260 +
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
261 +
			if err := clipboard.WriteAll(link); err != nil {
262 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
263 +
			}
264 +
			return m, m.setStatus("copied link", true)
265 +
		}
266 +
		return m, m.setStatus("local mode: no link", false)
267 +
	case key.Matches(msg, m.keys.OpenBrowser):
268 +
		n := m.currentNote()
269 +
		if n != nil && m.isRemote {
270 +
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
271 +
			if err := openURL(link); err != nil {
272 +
				return m, m.setStatus("open: "+err.Error(), false)
273 +
			}
274 +
			return m, m.setStatus("opened "+link, true)
275 +
		}
276 +
	case key.Matches(msg, m.keys.Search):
277 +
		m.focus = FocusSearch
278 +
		m.searchInput.Focus()
279 +
	case key.Matches(msg, m.keys.Refresh):
280 +
		if m.isRemote {
281 +
			m.loading = true
282 +
			return m, loadNotesCmd(m.backend)
283 +
		}
284 +
	case key.Matches(msg, m.keys.Help):
285 +
		m.showHelp = true
286 +
	}
287 +
	return m, nil
288 +
}
289 +
290 +
func (m Model) keyContent(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
291 +
	switch {
292 +
	case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back):
293 +
		m.focus = FocusList
294 +
		return m, nil
295 +
	case key.Matches(msg, m.keys.Down):
296 +
		m.contentVP.LineDown(1)
297 +
	case key.Matches(msg, m.keys.Up):
298 +
		m.contentVP.LineUp(1)
299 +
	case key.Matches(msg, m.keys.Edit):
300 +
		n := m.currentNote()
301 +
		if n != nil {
302 +
			m.focus = FocusEditTitle
303 +
			m.editShortID = n.ShortID
304 +
			m.titleInput.SetValue(n.Title)
305 +
			m.contentArea.SetValue(n.Content)
306 +
			m.titleInput.Focus()
307 +
		}
308 +
	case key.Matches(msg, m.keys.ExtEdit):
309 +
		n := m.currentNote()
310 +
		if n != nil {
311 +
			return m, openExternalEditor(n.ShortID, n.Content)
312 +
		}
313 +
	case key.Matches(msg, m.keys.Copy):
314 +
		n := m.currentNote()
315 +
		if n != nil {
316 +
			clipboard.WriteAll(n.Content)
317 +
			return m, m.setStatus("copied text", true)
318 +
		}
319 +
	case key.Matches(msg, m.keys.CopyLink):
320 +
		n := m.currentNote()
321 +
		if n != nil && m.isRemote {
322 +
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
323 +
			clipboard.WriteAll(link)
324 +
			return m, m.setStatus("copied link", true)
325 +
		}
326 +
	case key.Matches(msg, m.keys.OpenBrowser):
327 +
		n := m.currentNote()
328 +
		if n != nil && m.isRemote {
329 +
			openURL(strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID)
330 +
		}
331 +
	case key.Matches(msg, m.keys.Help):
332 +
		m.showHelp = true
333 +
	case key.Matches(msg, m.keys.ToggleWrap):
334 +
		m.wrap = !m.wrap
335 +
		m.refreshPreview()
336 +
	}
337 +
	var cmd tea.Cmd
338 +
	m.contentVP, cmd = m.contentVP.Update(msg)
339 +
	return m, cmd
340 +
}
341 +
342 +
func (m Model) keyForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
343 +
	switch {
344 +
	case key.Matches(msg, m.keys.Cancel):
345 +
		m.focus = FocusList
346 +
		m.titleInput.Blur()
347 +
		m.contentArea.Blur()
348 +
		return m, nil
349 +
	case key.Matches(msg, m.keys.Save):
350 +
		title := strings.TrimSpace(m.titleInput.Value())
351 +
		if title == "" {
352 +
			return m, m.setStatus("title required", false)
353 +
		}
354 +
		return m, saveNoteCmd(m.backend, m.editShortID, title, m.contentArea.Value())
355 +
	case key.Matches(msg, m.keys.SwitchField):
356 +
		switch m.focus {
357 +
		case FocusCreateTitle:
358 +
			m.focus = FocusCreateContent
359 +
		case FocusCreateContent:
360 +
			m.focus = FocusCreateTitle
361 +
		case FocusEditTitle:
362 +
			m.focus = FocusEditContent
363 +
		case FocusEditContent:
364 +
			m.focus = FocusEditTitle
365 +
		}
366 +
		m.applyFormFocus()
367 +
		return m, nil
368 +
	case key.Matches(msg, m.keys.ToggleWrap):
369 +
		m.wrap = !m.wrap
370 +
		return m, nil
371 +
	}
372 +
373 +
	var cmd tea.Cmd
374 +
	switch m.focus {
375 +
	case FocusCreateTitle, FocusEditTitle:
376 +
		m.titleInput, cmd = m.titleInput.Update(msg)
377 +
	case FocusCreateContent, FocusEditContent:
378 +
		m.contentArea, cmd = m.contentArea.Update(msg)
379 +
	}
380 +
	return m, cmd
381 +
}
382 +
383 +
func (m *Model) applyFormFocus() {
384 +
	switch m.focus {
385 +
	case FocusCreateTitle, FocusEditTitle:
386 +
		m.titleInput.Focus()
387 +
		m.contentArea.Blur()
388 +
	case FocusCreateContent, FocusEditContent:
389 +
		m.contentArea.Focus()
390 +
		m.titleInput.Blur()
391 +
	}
392 +
}
393 +
394 +
func (m Model) keySearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
395 +
	switch msg.String() {
396 +
	case "esc":
397 +
		m.searchInput.SetValue("")
398 +
		m.searchInput.Blur()
399 +
		m.focus = FocusList
400 +
		m.applyFilter("")
401 +
		m.refreshPreview()
402 +
		return m, nil
403 +
	case "enter":
404 +
		m.searchInput.Blur()
405 +
		m.focus = FocusList
406 +
		return m, nil
407 +
	}
408 +
	var cmd tea.Cmd
409 +
	m.searchInput, cmd = m.searchInput.Update(msg)
410 +
	m.applyFilter(m.searchInput.Value())
411 +
	m.refreshPreview()
412 +
	return m, cmd
413 +
}
apps/jotts-go/tui/view.go (added) +183 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"strings"
6 +
7 +
	"github.com/charmbracelet/lipgloss"
8 +
)
9 +
10 +
var (
11 +
	borderStyle = lipgloss.NewStyle().
12 +
			Border(lipgloss.RoundedBorder()).
13 +
			BorderForeground(lipgloss.Color("240"))
14 +
	borderActive = lipgloss.NewStyle().
15 +
			Border(lipgloss.RoundedBorder()).
16 +
			BorderForeground(lipgloss.Color("214"))
17 +
	titleStyle = lipgloss.NewStyle().
18 +
			Bold(true).
19 +
			Foreground(lipgloss.Color("214")).
20 +
			Padding(0, 1)
21 +
	itemStyle = lipgloss.NewStyle().Padding(0, 1)
22 +
	itemSelected = lipgloss.NewStyle().
23 +
			Padding(0, 1).
24 +
			Bold(true).
25 +
			Foreground(lipgloss.Color("214"))
26 +
	statusOK = lipgloss.NewStyle().
27 +
			Foreground(lipgloss.Color("82")).
28 +
			Bold(true)
29 +
	statusErr = lipgloss.NewStyle().
30 +
			Foreground(lipgloss.Color("196")).
31 +
			Bold(true)
32 +
	hintStyle = lipgloss.NewStyle().
33 +
			Foreground(lipgloss.Color("244"))
34 +
	modalStyle = lipgloss.NewStyle().
35 +
			Border(lipgloss.RoundedBorder()).
36 +
			BorderForeground(lipgloss.Color("214")).
37 +
			Padding(1, 2).
38 +
			Background(lipgloss.Color("236"))
39 +
)
40 +
41 +
func (m Model) View() string {
42 +
	if !m.ready {
43 +
		return "loading..."
44 +
	}
45 +
46 +
	listW := m.width * 30 / 100
47 +
	if listW < 24 {
48 +
		listW = 24
49 +
	}
50 +
	contentW := m.width - listW - 2
51 +
	bodyH := m.height - 2
52 +
53 +
	left := m.renderList(listW, bodyH)
54 +
	right := m.renderRight(contentW, bodyH)
55 +
56 +
	body := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
57 +
	footer := m.renderFooter()
58 +
59 +
	view := lipgloss.JoinVertical(lipgloss.Left, body, footer)
60 +
61 +
	if m.showHelp {
62 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
63 +
			modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())),
64 +
			lipgloss.WithWhitespaceChars(" "))
65 +
	}
66 +
	if m.confirmDelete {
67 +
		n := m.currentNote()
68 +
		title := ""
69 +
		if n != nil {
70 +
			title = n.Title
71 +
		}
72 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
73 +
			modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", title)),
74 +
			lipgloss.WithWhitespaceChars(" "))
75 +
	}
76 +
	if m.status != "" {
77 +
		st := statusOK
78 +
		if !m.statusOK {
79 +
			st = statusErr
80 +
		}
81 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Bottom,
82 +
			modalStyle.Render(st.Render(m.status)),
83 +
			lipgloss.WithWhitespaceChars(" "))
84 +
	}
85 +
	return view
86 +
}
87 +
88 +
func (m Model) renderList(w, h int) string {
89 +
	style := borderStyle
90 +
	if m.focus == FocusList || m.focus == FocusSearch {
91 +
		style = borderActive
92 +
	}
93 +
94 +
	notes := m.visibleNotes()
95 +
	rows := make([]string, 0, len(notes)+2)
96 +
	rows = append(rows, titleStyle.Render("notes"))
97 +
	if len(notes) == 0 {
98 +
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
99 +
	}
100 +
	for i, n := range notes {
101 +
		line := truncate(n.Title, w-6)
102 +
		if i == m.cursor {
103 +
			rows = append(rows, itemSelected.Render("▶ "+line))
104 +
		} else {
105 +
			rows = append(rows, itemStyle.Render("  "+line))
106 +
		}
107 +
	}
108 +
109 +
	if m.focus == FocusSearch || m.searchInput.Value() != "" {
110 +
		rows = append(rows, "", hintStyle.Render(m.searchInput.View()))
111 +
	}
112 +
113 +
	content := strings.Join(rows, "\n")
114 +
	return style.Width(w).Height(h).Render(content)
115 +
}
116 +
117 +
func (m Model) renderRight(w, h int) string {
118 +
	switch m.focus {
119 +
	case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent:
120 +
		return m.renderForm(w, h)
121 +
	}
122 +
	return m.renderContent(w, h)
123 +
}
124 +
125 +
func (m Model) renderContent(w, h int) string {
126 +
	style := borderStyle
127 +
	if m.focus == FocusContent {
128 +
		style = borderActive
129 +
	}
130 +
	header := "preview"
131 +
	n := m.currentNote()
132 +
	if n != nil {
133 +
		header = n.Title
134 +
	}
135 +
	body := m.contentVP.View()
136 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
137 +
	return style.Width(w).Height(h).Render(inner)
138 +
}
139 +
140 +
func (m Model) renderForm(w, h int) string {
141 +
	header := "new note"
142 +
	if m.editShortID != "" {
143 +
		header = "edit"
144 +
	}
145 +
	title := m.titleInput.View()
146 +
	if m.focus == FocusCreateTitle || m.focus == FocusEditTitle {
147 +
		title = borderActive.Render(title)
148 +
	} else {
149 +
		title = borderStyle.Render(title)
150 +
	}
151 +
152 +
	body := m.contentArea.View()
153 +
	if m.focus == FocusCreateContent || m.focus == FocusEditContent {
154 +
		body = borderActive.Render(body)
155 +
	} else {
156 +
		body = borderStyle.Render(body)
157 +
	}
158 +
159 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), title, body)
160 +
	return borderStyle.Width(w).Height(h).Render(inner)
161 +
}
162 +
163 +
func (m Model) renderFooter() string {
164 +
	mode := "local"
165 +
	if m.isRemote {
166 +
		mode = "remote " + m.backend.RemoteURL()
167 +
	}
168 +
	help := m.help.ShortHelpView(m.keys.ShortHelp())
169 +
	return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help))
170 +
}
171 +
172 +
func truncate(s string, n int) string {
173 +
	if n < 1 {
174 +
		return ""
175 +
	}
176 +
	if len(s) <= n {
177 +
		return s
178 +
	}
179 +
	if n <= 1 {
180 +
		return "…"
181 +
	}
182 +
	return s[:n-1] + "…"
183 +
}
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) +242 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
	"time"
8 +
)
9 +
10 +
const booksSchema = `
11 +
CREATE TABLE IF NOT EXISTS books (
12 +
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
13 +
    google_id     TEXT UNIQUE,
14 +
    title         TEXT NOT NULL,
15 +
    authors       TEXT NOT NULL,
16 +
    isbn          TEXT,
17 +
    cover_url     TEXT,
18 +
    notes         TEXT,
19 +
    status        TEXT NOT NULL CHECK (status IN ('read','reading','want')),
20 +
    added_at      INTEGER NOT NULL,
21 +
    updated_at    INTEGER NOT NULL
22 +
);
23 +
CREATE INDEX IF NOT EXISTS idx_books_status_added ON books(status, added_at DESC);
24 +
25 +
CREATE TABLE IF NOT EXISTS settings (
26 +
    key   TEXT PRIMARY KEY,
27 +
    value TEXT NOT NULL
28 +
);
29 +
`
30 +
31 +
type Book struct {
32 +
	ID        int64   `json:"id"`
33 +
	GoogleID  *string `json:"google_id,omitempty"`
34 +
	Title     string  `json:"title"`
35 +
	Authors   string  `json:"authors"`
36 +
	ISBN      *string `json:"isbn,omitempty"`
37 +
	CoverURL  *string `json:"cover_url,omitempty"`
38 +
	Notes     *string `json:"notes,omitempty"`
39 +
	Status    string  `json:"status"`
40 +
	AddedAt   int64   `json:"added_at"`
41 +
	UpdatedAt int64   `json:"updated_at"`
42 +
}
43 +
44 +
type NewBook struct {
45 +
	GoogleID *string
46 +
	Title    string
47 +
	Authors  string
48 +
	ISBN     *string
49 +
	CoverURL *string
50 +
	Notes    *string
51 +
	Status   string
52 +
}
53 +
54 +
type CategoryLabels struct {
55 +
	Reading string
56 +
	Read    string
57 +
	Want    string
58 +
}
59 +
60 +
func defaultLabels() CategoryLabels {
61 +
	return CategoryLabels{Reading: "Reading", Read: "Read", Want: "Want to Read"}
62 +
}
63 +
64 +
const selectCols = `id, google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at`
65 +
66 +
func scanBook(s interface{ Scan(...any) error }) (*Book, error) {
67 +
	var b Book
68 +
	var gid, isbn, cover, notes sql.NullString
69 +
	err := s.Scan(&b.ID, &gid, &b.Title, &b.Authors, &isbn, &cover, &notes, &b.Status, &b.AddedAt, &b.UpdatedAt)
70 +
	if errors.Is(err, sql.ErrNoRows) {
71 +
		return nil, nil
72 +
	}
73 +
	if err != nil {
74 +
		return nil, err
75 +
	}
76 +
	if gid.Valid {
77 +
		v := gid.String
78 +
		b.GoogleID = &v
79 +
	}
80 +
	if isbn.Valid {
81 +
		v := isbn.String
82 +
		b.ISBN = &v
83 +
	}
84 +
	if cover.Valid {
85 +
		v := cover.String
86 +
		b.CoverURL = &v
87 +
	}
88 +
	if notes.Valid {
89 +
		v := notes.String
90 +
		b.Notes = &v
91 +
	}
92 +
	return &b, nil
93 +
}
94 +
95 +
func listBooks(db *sql.DB, status string) ([]Book, error) {
96 +
	var rows *sql.Rows
97 +
	var err error
98 +
	if status != "" {
99 +
		rows, err = db.Query(`SELECT `+selectCols+` FROM books WHERE status = ? ORDER BY added_at DESC`, status)
100 +
	} else {
101 +
		rows, err = db.Query(`SELECT ` + selectCols + ` FROM books ORDER BY added_at DESC`)
102 +
	}
103 +
	if err != nil {
104 +
		return nil, err
105 +
	}
106 +
	defer rows.Close()
107 +
	var out []Book
108 +
	for rows.Next() {
109 +
		b, err := scanBook(rows)
110 +
		if err != nil {
111 +
			return nil, err
112 +
		}
113 +
		out = append(out, *b)
114 +
	}
115 +
	return out, rows.Err()
116 +
}
117 +
118 +
func getBook(db *sql.DB, id int64) (*Book, error) {
119 +
	return scanBook(db.QueryRow(`SELECT `+selectCols+` FROM books WHERE id = ?`, id))
120 +
}
121 +
122 +
func insertBook(db *sql.DB, b NewBook) (int64, error) {
123 +
	now := time.Now().UTC().Unix()
124 +
	var gid, isbn, cover, notes any
125 +
	if b.GoogleID != nil && *b.GoogleID != "" {
126 +
		gid = *b.GoogleID
127 +
	}
128 +
	if b.ISBN != nil && *b.ISBN != "" {
129 +
		isbn = *b.ISBN
130 +
	}
131 +
	if b.CoverURL != nil && *b.CoverURL != "" {
132 +
		cover = *b.CoverURL
133 +
	}
134 +
	if b.Notes != nil && *b.Notes != "" {
135 +
		notes = *b.Notes
136 +
	}
137 +
	res, err := db.Exec(
138 +
		`INSERT INTO books (google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at)
139 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
140 +
		 ON CONFLICT(google_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
141 +
		gid, b.Title, b.Authors, isbn, cover, notes, b.Status, now, now,
142 +
	)
143 +
	if err != nil {
144 +
		return 0, err
145 +
	}
146 +
	return res.LastInsertId()
147 +
}
148 +
149 +
func updateBookStatus(db *sql.DB, id int64, status string) error {
150 +
	_, err := db.Exec(`UPDATE books SET status = ?, updated_at = ? WHERE id = ?`, status, time.Now().UTC().Unix(), id)
151 +
	return err
152 +
}
153 +
154 +
func updateBookNotes(db *sql.DB, id int64, notes *string) error {
155 +
	var v any
156 +
	if notes != nil && *notes != "" {
157 +
		v = *notes
158 +
	}
159 +
	_, err := db.Exec(`UPDATE books SET notes = ?, updated_at = ? WHERE id = ?`, v, time.Now().UTC().Unix(), id)
160 +
	return err
161 +
}
162 +
163 +
func deleteBook(db *sql.DB, id int64) error {
164 +
	_, err := db.Exec(`DELETE FROM books WHERE id = ?`, id)
165 +
	return err
166 +
}
167 +
168 +
func searchBooks(db *sql.DB, q string) ([]Book, error) {
169 +
	term := strings.TrimSpace(q)
170 +
	if term == "" {
171 +
		return nil, nil
172 +
	}
173 +
	pattern := "%" + strings.ToLower(term) + "%"
174 +
	rows, err := db.Query(
175 +
		`SELECT `+selectCols+` FROM books
176 +
		 WHERE LOWER(title) LIKE ? OR LOWER(authors) LIKE ? OR LOWER(IFNULL(isbn,'')) LIKE ?
177 +
		 ORDER BY added_at DESC LIMIT 50`,
178 +
		pattern, pattern, pattern,
179 +
	)
180 +
	if err != nil {
181 +
		return nil, err
182 +
	}
183 +
	defer rows.Close()
184 +
	var out []Book
185 +
	for rows.Next() {
186 +
		b, err := scanBook(rows)
187 +
		if err != nil {
188 +
			return nil, err
189 +
		}
190 +
		out = append(out, *b)
191 +
	}
192 +
	return out, rows.Err()
193 +
}
194 +
195 +
func getSetting(db *sql.DB, key string) (string, bool, error) {
196 +
	var v string
197 +
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
198 +
	if errors.Is(err, sql.ErrNoRows) {
199 +
		return "", false, nil
200 +
	}
201 +
	if err != nil {
202 +
		return "", false, err
203 +
	}
204 +
	return v, true, nil
205 +
}
206 +
207 +
func setSetting(db *sql.DB, key, value string) error {
208 +
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
209 +
	return err
210 +
}
211 +
212 +
func getCategoryLabels(db *sql.DB) (CategoryLabels, error) {
213 +
	labels := defaultLabels()
214 +
	if v, ok, err := getSetting(db, "category_label.reading"); err == nil && ok {
215 +
		labels.Reading = v
216 +
	} else if err != nil {
217 +
		return labels, err
218 +
	}
219 +
	if v, ok, err := getSetting(db, "category_label.read"); err == nil && ok {
220 +
		labels.Read = v
221 +
	}
222 +
	if v, ok, err := getSetting(db, "category_label.want"); err == nil && ok {
223 +
		labels.Want = v
224 +
	}
225 +
	return labels, nil
226 +
}
227 +
228 +
func labelFor(l CategoryLabels, status string) string {
229 +
	switch status {
230 +
	case "reading":
231 +
		return l.Reading
232 +
	case "read":
233 +
		return l.Read
234 +
	case "want":
235 +
		return l.Want
236 +
	}
237 +
	return status
238 +
}
239 +
240 +
func validStatus(s string) bool {
241 +
	return s == "read" || s == "reading" || s == "want"
242 +
}
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) +34 −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/sqlite v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
11 +
)
12 +
13 +
require (
14 +
	github.com/dustin/go-humanize v1.0.1 // indirect
15 +
	github.com/google/uuid v1.6.0 // indirect
16 +
	github.com/mattn/go-isatty v0.0.20 // indirect
17 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
18 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
19 +
	golang.org/x/crypto v0.39.0 // indirect
20 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
21 +
	golang.org/x/sys v0.33.0 // indirect
22 +
	modernc.org/libc v1.65.7 // indirect
23 +
	modernc.org/mathutil v1.7.1 // indirect
24 +
	modernc.org/memory v1.11.0 // indirect
25 +
	modernc.org/sqlite v1.37.1 // indirect
26 +
)
27 +
28 +
replace (
29 +
	github.com/stevedylandev/andromeda/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/sqlite => ../../crates-go/sqlite
33 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
34 +
)
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) +49 −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) apiListBooks(w http.ResponseWriter, r *http.Request) {
10 +
	status := r.URL.Query().Get("status")
11 +
	switch status {
12 +
	case "", "all":
13 +
		status = ""
14 +
	default:
15 +
		if !validStatus(status) {
16 +
			web.WriteError(w, http.StatusBadRequest, "invalid status")
17 +
			return
18 +
		}
19 +
	}
20 +
	books, err := listBooks(a.DB, status)
21 +
	if err != nil {
22 +
		a.Log.Error("list books", "err", err)
23 +
		w.WriteHeader(http.StatusInternalServerError)
24 +
		return
25 +
	}
26 +
	if books == nil {
27 +
		books = []Book{}
28 +
	}
29 +
	web.WriteJSON(w, http.StatusOK, books)
30 +
}
31 +
32 +
func (a *App) apiGetBook(w http.ResponseWriter, r *http.Request) {
33 +
	id, ok := web.PathInt64(r, "id")
34 +
	if !ok {
35 +
		web.WriteError(w, http.StatusBadRequest, "invalid id")
36 +
		return
37 +
	}
38 +
	b, err := getBook(a.DB, id)
39 +
	if err != nil {
40 +
		a.Log.Error("get book", "err", err)
41 +
		w.WriteHeader(http.StatusInternalServerError)
42 +
		return
43 +
	}
44 +
	if b == nil {
45 +
		web.WriteError(w, http.StatusNotFound, "not found")
46 +
		return
47 +
	}
48 +
	web.WriteJSON(w, http.StatusOK, b)
49 +
}
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.WriteError(w, http.StatusBadGateway, 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) +50 −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 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
13 +
)
14 +
15 +
func main() {
16 +
	config.LoadDotEnv(".env")
17 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
18 +
19 +
	dbPath := config.Getenv("LIBRARY_DB_PATH", "library.sqlite")
20 +
	db, err := sqlite.Open(dbPath, booksSchema)
21 +
	if err != nil {
22 +
		log.Fatal(err)
23 +
	}
24 +
	defer db.Close()
25 +
26 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
27 +
	if err := sessions.EnsureSchema(); err != nil {
28 +
		log.Fatal(err)
29 +
	}
30 +
	sessions.PruneExpired()
31 +
32 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
33 +
	app := &App{
34 +
		DB:             db,
35 +
		Log:            logger,
36 +
		Templates:      tmpl,
37 +
		Sessions:       sessions,
38 +
		AdminPassword:  os.Getenv("ADMIN_PASSWORD"),
39 +
		GoogleBooksKey: os.Getenv("GOOGLE_BOOKS_API_KEY"),
40 +
		CookieSecure:   sessions.CookieSecure,
41 +
		BaseURL:        config.Getenv("BASE_URL", "http://localhost:3000"),
42 +
		DisplayMode:    parseDisplayMode(config.Getenv("LIBRARY_DISPLAY_MODE", "inline")),
43 +
	}
44 +
45 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
46 +
	logger.Info("library-go server running", "addr", addr)
47 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
48 +
		log.Fatal(err)
49 +
	}
50 +
}
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) +442 −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 +
)
11 +
12 +
const postsSchema = `
13 +
CREATE TABLE IF NOT EXISTS posts (
14 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
15 +
    short_id        TEXT NOT NULL UNIQUE,
16 +
    title           TEXT,
17 +
    slug            TEXT NOT NULL UNIQUE,
18 +
    alias           TEXT,
19 +
    canonical_url   TEXT,
20 +
    published_date  TEXT,
21 +
    meta_description TEXT,
22 +
    meta_image      TEXT,
23 +
    lang            TEXT NOT NULL DEFAULT 'en',
24 +
    tags            TEXT,
25 +
    content         TEXT NOT NULL,
26 +
    status          TEXT NOT NULL DEFAULT 'draft',
27 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
28 +
    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
29 +
);
30 +
31 +
CREATE TABLE IF NOT EXISTS pages (
32 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
33 +
    short_id        TEXT NOT NULL UNIQUE,
34 +
    title           TEXT NOT NULL,
35 +
    slug            TEXT NOT NULL UNIQUE,
36 +
    content         TEXT NOT NULL,
37 +
    is_published    INTEGER NOT NULL DEFAULT 0,
38 +
    nav_order       INTEGER NOT NULL DEFAULT 0,
39 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
40 +
    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
41 +
);
42 +
43 +
CREATE TABLE IF NOT EXISTS settings (
44 +
    key   TEXT PRIMARY KEY,
45 +
    value TEXT NOT NULL
46 +
);
47 +
48 +
CREATE TABLE IF NOT EXISTS files (
49 +
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
50 +
    short_id        TEXT NOT NULL UNIQUE,
51 +
    filename        TEXT NOT NULL UNIQUE,
52 +
    original_name   TEXT NOT NULL,
53 +
    content_type    TEXT NOT NULL DEFAULT 'application/octet-stream',
54 +
    size            INTEGER NOT NULL,
55 +
    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
56 +
    storage_backend TEXT NOT NULL DEFAULT 'local'
57 +
);
58 +
`
59 +
60 +
var defaultSettings = [][2]string{
61 +
	{"blog_title", "My Blog"},
62 +
	{"blog_description", "A simple blog"},
63 +
	{"intro_content", ""},
64 +
	{"nav_links", "[blog](/) [posts](/posts)"},
65 +
	{"custom_css", ""},
66 +
	{"favicon_url", ""},
67 +
	{"og_image_url", ""},
68 +
	{"custom_header", ""},
69 +
	{"custom_footer", `<div>
70 +
<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>
71 +
</div>`},
72 +
}
73 +
74 +
func seedDefaultSettings(db *sql.DB) {
75 +
	for _, kv := range defaultSettings {
76 +
		_, _ = db.Exec(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, kv[0], kv[1])
77 +
	}
78 +
}
79 +
80 +
const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at`
81 +
82 +
func scanPost(s interface{ Scan(...any) error }) (*Post, error) {
83 +
	var p Post
84 +
	var title, alias, canonicalURL, publishedDate, metaDesc, metaImage, tags sql.NullString
85 +
	err := s.Scan(&p.ID, &p.ShortID, &title, &p.Slug, &alias, &canonicalURL,
86 +
		&publishedDate, &metaDesc, &metaImage, &p.Lang, &tags, &p.Content,
87 +
		&p.Status, &p.CreatedAt, &p.UpdatedAt)
88 +
	if errors.Is(err, sql.ErrNoRows) {
89 +
		return nil, nil
90 +
	}
91 +
	if err != nil {
92 +
		return nil, err
93 +
	}
94 +
	if title.Valid {
95 +
		v := title.String
96 +
		p.Title = &v
97 +
	}
98 +
	if alias.Valid {
99 +
		v := alias.String
100 +
		p.Alias = &v
101 +
	}
102 +
	if canonicalURL.Valid {
103 +
		v := canonicalURL.String
104 +
		p.CanonicalURL = &v
105 +
	}
106 +
	if publishedDate.Valid {
107 +
		v := publishedDate.String
108 +
		p.PublishedDate = &v
109 +
	}
110 +
	if metaDesc.Valid {
111 +
		v := metaDesc.String
112 +
		p.MetaDescription = &v
113 +
	}
114 +
	if metaImage.Valid {
115 +
		v := metaImage.String
116 +
		p.MetaImage = &v
117 +
	}
118 +
	if tags.Valid {
119 +
		v := tags.String
120 +
		p.Tags = &v
121 +
	}
122 +
	return &p, nil
123 +
}
124 +
125 +
type PostInput struct {
126 +
	Title           *string
127 +
	Slug            string
128 +
	Content         string
129 +
	Status          string
130 +
	Alias           *string
131 +
	CanonicalURL    *string
132 +
	PublishedDate   *string
133 +
	MetaDescription *string
134 +
	MetaImage       *string
135 +
	Lang            string
136 +
	Tags            *string
137 +
}
138 +
139 +
func nullable(p *string) any {
140 +
	if p == nil {
141 +
		return nil
142 +
	}
143 +
	return *p
144 +
}
145 +
146 +
func createPost(db *sql.DB, in PostInput) (*Post, error) {
147 +
	shortID, err := auth.GenerateShortID(10)
148 +
	if err != nil {
149 +
		return nil, err
150 +
	}
151 +
	res, err := db.Exec(
152 +
		`INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags)
153 +
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
154 +
		shortID, nullable(in.Title), in.Slug, in.Content, in.Status,
155 +
		nullable(in.Alias), nullable(in.CanonicalURL), nullable(in.PublishedDate),
156 +
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags),
157 +
	)
158 +
	if err != nil {
159 +
		return nil, err
160 +
	}
161 +
	id, _ := res.LastInsertId()
162 +
	return scanPost(db.QueryRow(`SELECT `+postCols+` FROM posts WHERE id = ?`, id))
163 +
}
164 +
165 +
func getPostByShortID(db *sql.DB, shortID string) (*Post, error) {
166 +
	return scanPost(db.QueryRow(`SELECT `+postCols+` FROM posts WHERE short_id = ?`, shortID))
167 +
}
168 +
169 +
func getPostBySlug(db *sql.DB, slug string) (*Post, error) {
170 +
	return scanPost(db.QueryRow(`SELECT `+postCols+` FROM posts WHERE slug = ?`, slug))
171 +
}
172 +
173 +
func getAllPosts(db *sql.DB) ([]Post, error) {
174 +
	rows, err := db.Query(`SELECT ` + postCols + ` FROM posts ORDER BY id DESC`)
175 +
	if err != nil {
176 +
		return nil, err
177 +
	}
178 +
	defer rows.Close()
179 +
	var out []Post
180 +
	for rows.Next() {
181 +
		p, err := scanPost(rows)
182 +
		if err != nil {
183 +
			return nil, err
184 +
		}
185 +
		out = append(out, *p)
186 +
	}
187 +
	return out, rows.Err()
188 +
}
189 +
190 +
func getPublishedPosts(db *sql.DB, limit int64) ([]Post, error) {
191 +
	if limit <= 0 {
192 +
		limit = -1
193 +
	}
194 +
	rows, err := db.Query(
195 +
		`SELECT `+postCols+` FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC LIMIT ?`, limit)
196 +
	if err != nil {
197 +
		return nil, err
198 +
	}
199 +
	defer rows.Close()
200 +
	var out []Post
201 +
	for rows.Next() {
202 +
		p, err := scanPost(rows)
203 +
		if err != nil {
204 +
			return nil, err
205 +
		}
206 +
		out = append(out, *p)
207 +
	}
208 +
	return out, rows.Err()
209 +
}
210 +
211 +
func updatePost(db *sql.DB, shortID string, in PostInput) (*Post, error) {
212 +
	res, err := db.Exec(
213 +
		`UPDATE posts SET title = ?, slug = ?, content = ?, status = ?, alias = ?, canonical_url = ?,
214 +
		 published_date = CASE WHEN ? = 'published' THEN COALESCE(?, published_date, datetime('now')) ELSE ? END,
215 +
		 meta_description = ?, meta_image = ?, lang = ?, tags = ?,
216 +
		 updated_at = datetime('now') WHERE short_id = ?`,
217 +
		nullable(in.Title), in.Slug, in.Content, in.Status, nullable(in.Alias), nullable(in.CanonicalURL),
218 +
		in.Status, nullable(in.PublishedDate), nullable(in.PublishedDate),
219 +
		nullable(in.MetaDescription), nullable(in.MetaImage), in.Lang, nullable(in.Tags), shortID,
220 +
	)
221 +
	if err != nil {
222 +
		return nil, err
223 +
	}
224 +
	if n, _ := res.RowsAffected(); n == 0 {
225 +
		return nil, nil
226 +
	}
227 +
	return getPostByShortID(db, shortID)
228 +
}
229 +
230 +
func deletePost(db *sql.DB, shortID string) (bool, error) {
231 +
	res, err := db.Exec(`DELETE FROM posts WHERE short_id = ?`, shortID)
232 +
	if err != nil {
233 +
		return false, err
234 +
	}
235 +
	n, _ := res.RowsAffected()
236 +
	return n > 0, nil
237 +
}
238 +
239 +
func togglePostStatus(db *sql.DB, shortID string) (string, error) {
240 +
	var current string
241 +
	err := db.QueryRow(`SELECT status FROM posts WHERE short_id = ?`, shortID).Scan(&current)
242 +
	if errors.Is(err, sql.ErrNoRows) {
243 +
		return "", nil
244 +
	}
245 +
	if err != nil {
246 +
		return "", err
247 +
	}
248 +
	newStatus := "published"
249 +
	if current == "published" {
250 +
		newStatus = "draft"
251 +
	}
252 +
	if newStatus == "published" {
253 +
		_, err = db.Exec(
254 +
			`UPDATE posts SET status = ?, published_date = COALESCE(published_date, datetime('now')), updated_at = datetime('now') WHERE short_id = ?`,
255 +
			newStatus, shortID)
256 +
	} else {
257 +
		_, err = db.Exec(`UPDATE posts SET status = ?, updated_at = datetime('now') WHERE short_id = ?`,
258 +
			newStatus, shortID)
259 +
	}
260 +
	return newStatus, err
261 +
}
262 +
263 +
func findAliasRedirect(db *sql.DB, alias string) (string, error) {
264 +
	var slug string
265 +
	err := db.QueryRow(`SELECT slug FROM posts WHERE alias = ? AND status = 'published'`, alias).Scan(&slug)
266 +
	if errors.Is(err, sql.ErrNoRows) {
267 +
		return "", nil
268 +
	}
269 +
	if err != nil {
270 +
		return "", err
271 +
	}
272 +
	return "/posts/" + slug, nil
273 +
}
274 +
275 +
const pageCols = `id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at`
276 +
277 +
func scanPage(s interface{ Scan(...any) error }) (*Page, error) {
278 +
	var p Page
279 +
	var pub int
280 +
	err := s.Scan(&p.ID, &p.ShortID, &p.Title, &p.Slug, &p.Content, &pub, &p.NavOrder, &p.CreatedAt, &p.UpdatedAt)
281 +
	if errors.Is(err, sql.ErrNoRows) {
282 +
		return nil, nil
283 +
	}
284 +
	if err != nil {
285 +
		return nil, err
286 +
	}
287 +
	p.IsPublished = pub != 0
288 +
	return &p, nil
289 +
}
290 +
291 +
func createPage(db *sql.DB, title, slug, content string, isPublished bool, navOrder int64) (*Page, error) {
292 +
	shortID, err := auth.GenerateShortID(10)
293 +
	if err != nil {
294 +
		return nil, err
295 +
	}
296 +
	pub := 0
297 +
	if isPublished {
298 +
		pub = 1
299 +
	}
300 +
	res, err := db.Exec(
301 +
		`INSERT INTO pages (short_id, title, slug, content, is_published, nav_order) VALUES (?, ?, ?, ?, ?, ?)`,
302 +
		shortID, title, slug, content, pub, navOrder)
303 +
	if err != nil {
304 +
		return nil, err
305 +
	}
306 +
	id, _ := res.LastInsertId()
307 +
	return scanPage(db.QueryRow(`SELECT `+pageCols+` FROM pages WHERE id = ?`, id))
308 +
}
309 +
310 +
func getPageByShortID(db *sql.DB, shortID string) (*Page, error) {
311 +
	return scanPage(db.QueryRow(`SELECT `+pageCols+` FROM pages WHERE short_id = ?`, shortID))
312 +
}
313 +
314 +
func getPageBySlug(db *sql.DB, slug string) (*Page, error) {
315 +
	return scanPage(db.QueryRow(`SELECT `+pageCols+` FROM pages WHERE slug = ?`, slug))
316 +
}
317 +
318 +
func getAllPages(db *sql.DB) ([]Page, error) {
319 +
	rows, err := db.Query(`SELECT ` + pageCols + ` FROM pages ORDER BY nav_order ASC, id ASC`)
320 +
	if err != nil {
321 +
		return nil, err
322 +
	}
323 +
	defer rows.Close()
324 +
	var out []Page
325 +
	for rows.Next() {
326 +
		p, err := scanPage(rows)
327 +
		if err != nil {
328 +
			return nil, err
329 +
		}
330 +
		out = append(out, *p)
331 +
	}
332 +
	return out, rows.Err()
333 +
}
334 +
335 +
func updatePage(db *sql.DB, shortID, title, slug, content string, isPublished bool, navOrder int64) (*Page, error) {
336 +
	pub := 0
337 +
	if isPublished {
338 +
		pub = 1
339 +
	}
340 +
	res, err := db.Exec(
341 +
		`UPDATE pages SET title = ?, slug = ?, content = ?, is_published = ?, nav_order = ?, updated_at = datetime('now') WHERE short_id = ?`,
342 +
		title, slug, content, pub, navOrder, shortID)
343 +
	if err != nil {
344 +
		return nil, err
345 +
	}
346 +
	if n, _ := res.RowsAffected(); n == 0 {
347 +
		return nil, nil
348 +
	}
349 +
	return getPageByShortID(db, shortID)
350 +
}
351 +
352 +
func deletePage(db *sql.DB, shortID string) error {
353 +
	_, err := db.Exec(`DELETE FROM pages WHERE short_id = ?`, shortID)
354 +
	return err
355 +
}
356 +
357 +
func getSetting(db *sql.DB, key string) (string, error) {
358 +
	var v string
359 +
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
360 +
	if errors.Is(err, sql.ErrNoRows) {
361 +
		return "", nil
362 +
	}
363 +
	if err != nil {
364 +
		return "", err
365 +
	}
366 +
	return v, nil
367 +
}
368 +
369 +
func setSetting(db *sql.DB, key, value string) error {
370 +
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
371 +
		key, value)
372 +
	return err
373 +
}
374 +
375 +
const fileCols = `id, short_id, filename, original_name, content_type, size, created_at, storage_backend`
376 +
377 +
func scanFile(s interface{ Scan(...any) error }) (*UploadedFile, error) {
378 +
	var f UploadedFile
379 +
	err := s.Scan(&f.ID, &f.ShortID, &f.Filename, &f.OriginalName, &f.ContentType, &f.Size, &f.CreatedAt, &f.StorageBackend)
380 +
	if errors.Is(err, sql.ErrNoRows) {
381 +
		return nil, nil
382 +
	}
383 +
	if err != nil {
384 +
		return nil, err
385 +
	}
386 +
	return &f, nil
387 +
}
388 +
389 +
func createFile(db *sql.DB, filename, originalName, contentType string, size int64) (*UploadedFile, error) {
390 +
	shortID, err := auth.GenerateShortID(10)
391 +
	if err != nil {
392 +
		return nil, err
393 +
	}
394 +
	res, err := db.Exec(
395 +
		`INSERT INTO files (short_id, filename, original_name, content_type, size, storage_backend) VALUES (?, ?, ?, ?, ?, 'local')`,
396 +
		shortID, filename, originalName, contentType, size)
397 +
	if err != nil {
398 +
		return nil, err
399 +
	}
400 +
	id, _ := res.LastInsertId()
401 +
	return scanFile(db.QueryRow(`SELECT `+fileCols+` FROM files WHERE id = ?`, id))
402 +
}
403 +
404 +
func getFileByFilename(db *sql.DB, filename string) (*UploadedFile, error) {
405 +
	return scanFile(db.QueryRow(`SELECT `+fileCols+` FROM files WHERE filename = ?`, filename))
406 +
}
407 +
408 +
func getAllFiles(db *sql.DB) ([]UploadedFile, error) {
409 +
	rows, err := db.Query(`SELECT ` + fileCols + ` FROM files ORDER BY id DESC`)
410 +
	if err != nil {
411 +
		return nil, err
412 +
	}
413 +
	defer rows.Close()
414 +
	var out []UploadedFile
415 +
	for rows.Next() {
416 +
		f, err := scanFile(rows)
417 +
		if err != nil {
418 +
			return nil, err
419 +
		}
420 +
		out = append(out, *f)
421 +
	}
422 +
	return out, rows.Err()
423 +
}
424 +
425 +
func deleteFile(db *sql.DB, shortID string) (*UploadedFile, error) {
426 +
	f, err := scanFile(db.QueryRow(`SELECT `+fileCols+` FROM files WHERE short_id = ?`, shortID))
427 +
	if err != nil || f == nil {
428 +
		return f, err
429 +
	}
430 +
	if _, err := db.Exec(`DELETE FROM files WHERE short_id = ?`, shortID); err != nil {
431 +
		return nil, err
432 +
	}
433 +
	return f, nil
434 +
}
435 +
436 +
func nowDatetime() string {
437 +
	return time.Now().UTC().Format("2006-01-02 15:04:05")
438 +
}
439 +
440 +
func _useStrings() {
441 +
	_ = strings.TrimSpace
442 +
}
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) +35 −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/sqlite v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
11 +
	github.com/yuin/goldmark v1.7.8
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 +
	modernc.org/sqlite v1.37.1 // 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/sqlite => ../../crates-go/sqlite
34 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
35 +
)
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.WriteError(w, http.StatusInternalServerError, "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.WriteError(w, http.StatusInternalServerError, "internal server error")
86 +
		return
87 +
	}
88 +
	if post == nil || post.Status != "published" {
89 +
		web.WriteError(w, http.StatusNotFound, "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) +62 −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 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
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("POSTS_DB_PATH", "posts.sqlite")
21 +
	db, err := sqlite.Open(dbPath, postsSchema)
22 +
	if err != nil {
23 +
		log.Fatal(err)
24 +
	}
25 +
	defer db.Close()
26 +
	seedDefaultSettings(db)
27 +
28 +
	uploadsDir := config.Getenv("UPLOADS_DIR", "uploads")
29 +
	if err := ensureDir(uploadsDir); err != nil {
30 +
		log.Fatalf("create uploads dir: %v", err)
31 +
	}
32 +
33 +
	password := os.Getenv("POSTS_PASSWORD")
34 +
	if password == "" {
35 +
		logger.Warn("POSTS_PASSWORD not set, using default 'changeme'")
36 +
		password = "changeme"
37 +
	}
38 +
39 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
40 +
	if err := sessions.EnsureSchema(); err != nil {
41 +
		log.Fatal(err)
42 +
	}
43 +
	sessions.PruneExpired()
44 +
45 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
46 +
	app := &App{
47 +
		DB:           db,
48 +
		Log:          logger,
49 +
		Templates:    tmpl,
50 +
		Sessions:     sessions,
51 +
		AppPassword:  password,
52 +
		CookieSecure: sessions.CookieSecure,
53 +
		UploadsDir:   uploadsDir,
54 +
		SiteURL:      strings.TrimRight(config.Getenv("SITE_URL", "http://localhost:3000"), "/"),
55 +
	}
56 +
57 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
58 +
	logger.Info("posts-go server running", "addr", addr)
59 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
60 +
		log.Fatal(err)
61 +
	}
62 +
}
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) +203 −0
1 +
// Sipp CLI: minimal command dispatcher.
2 +
//
3 +
//	sipp                              launch the interactive TUI
4 +
//	sipp tui [-r URL] [-k KEY]        launch the interactive TUI
5 +
//	sipp auth                         save remote URL + API key to config
6 +
//	sipp server [--host H] [--port P] start the web server
7 +
//	sipp [-r URL] [-k KEY] <file>     upload a file to a remote sipp server
8 +
//	sipp --help
9 +
package main
10 +
11 +
import (
12 +
	"bufio"
13 +
	"bytes"
14 +
	"encoding/json"
15 +
	"fmt"
16 +
	"io"
17 +
	"net/http"
18 +
	"os"
19 +
	"path/filepath"
20 +
	"strconv"
21 +
	"strings"
22 +
23 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/server"
24 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/tui"
25 +
	"github.com/stevedylandev/andromeda/crates-go/config"
26 +
)
27 +
28 +
const usage = `sipp — minimal code sharing CLI
29 +
30 +
usage:
31 +
  sipp                              launch interactive TUI
32 +
  sipp tui [-r URL] [-k KEY]        launch interactive TUI
33 +
  sipp auth                         save remote URL + API key to ~/.config/sipp/config.toml
34 +
  sipp server [--host HOST] [--port PORT]
35 +
  sipp [-r URL] [-k KEY] <file>     create a snippet from FILE on the remote server
36 +
  sipp --help
37 +
38 +
env:
39 +
  SIPP_REMOTE_URL  default remote URL
40 +
  SIPP_API_KEY     API key used for authenticated requests
41 +
  SIPP_DB_PATH     local sqlite path for TUI in local mode
42 +
`
43 +
44 +
func main() {
45 +
	config.LoadDotEnv(".env")
46 +
	args := os.Args[1:]
47 +
	if len(args) == 0 {
48 +
		runTUI(nil)
49 +
		return
50 +
	}
51 +
	switch args[0] {
52 +
	case "-h", "--help":
53 +
		fmt.Print(usage)
54 +
	case "server":
55 +
		runServer(args[1:])
56 +
	case "tui":
57 +
		runTUI(args[1:])
58 +
	case "auth":
59 +
		runAuth()
60 +
	default:
61 +
		runUpload(args)
62 +
	}
63 +
}
64 +
65 +
func runServer(args []string) {
66 +
	host := config.Getenv("HOST", "127.0.0.1")
67 +
	port := config.GetenvInt("PORT", 3000)
68 +
	for i := 0; i < len(args); i++ {
69 +
		switch args[i] {
70 +
		case "--host":
71 +
			if i+1 < len(args) {
72 +
				host = args[i+1]
73 +
				i++
74 +
			}
75 +
		case "--port", "-p":
76 +
			if i+1 < len(args) {
77 +
				if n, err := strconv.Atoi(args[i+1]); err == nil {
78 +
					port = n
79 +
				}
80 +
				i++
81 +
			}
82 +
		}
83 +
	}
84 +
	if err := server.Run(host, port); err != nil {
85 +
		fmt.Fprintln(os.Stderr, err)
86 +
		os.Exit(1)
87 +
	}
88 +
}
89 +
90 +
func runTUI(args []string) {
91 +
	if err := tui.Run(tui.ParseArgs(args)); err != nil {
92 +
		fmt.Fprintln(os.Stderr, err)
93 +
		os.Exit(1)
94 +
	}
95 +
}
96 +
97 +
func runAuth() {
98 +
	cfg, _ := tui.LoadConfig()
99 +
	in := bufio.NewReader(os.Stdin)
100 +
101 +
	fmt.Printf("Remote URL [%s]: ", cfg.RemoteURL)
102 +
	url, _ := in.ReadString('\n')
103 +
	url = strings.TrimSpace(url)
104 +
	if url != "" {
105 +
		cfg.RemoteURL = url
106 +
	}
107 +
108 +
	masked := ""
109 +
	if cfg.APIKey != "" {
110 +
		masked = "********"
111 +
	}
112 +
	fmt.Printf("API key [%s]: ", masked)
113 +
	key, _ := in.ReadString('\n')
114 +
	key = strings.TrimSpace(key)
115 +
	if key != "" {
116 +
		cfg.APIKey = key
117 +
	}
118 +
119 +
	if err := tui.SaveConfig(cfg); err != nil {
120 +
		fmt.Fprintln(os.Stderr, "save config:", err)
121 +
		os.Exit(1)
122 +
	}
123 +
	path, _ := tui.ConfigPath()
124 +
	fmt.Println("saved", path)
125 +
}
126 +
127 +
func runUpload(args []string) {
128 +
	remote := os.Getenv("SIPP_REMOTE_URL")
129 +
	apiKey := os.Getenv("SIPP_API_KEY")
130 +
	var file string
131 +
	for i := 0; i < len(args); i++ {
132 +
		switch args[i] {
133 +
		case "-r", "--remote":
134 +
			if i+1 < len(args) {
135 +
				remote = args[i+1]
136 +
				i++
137 +
			}
138 +
		case "-k", "--api-key":
139 +
			if i+1 < len(args) {
140 +
				apiKey = args[i+1]
141 +
				i++
142 +
			}
143 +
		default:
144 +
			if !strings.HasPrefix(args[i], "-") {
145 +
				file = args[i]
146 +
			}
147 +
		}
148 +
	}
149 +
	if file == "" {
150 +
		fmt.Fprintln(os.Stderr, "no file specified")
151 +
		fmt.Fprint(os.Stderr, usage)
152 +
		os.Exit(2)
153 +
	}
154 +
	if remote == "" {
155 +
		cfg, _ := tui.LoadConfig()
156 +
		if cfg.RemoteURL != "" {
157 +
			remote = cfg.RemoteURL
158 +
		}
159 +
		if apiKey == "" {
160 +
			apiKey = cfg.APIKey
161 +
		}
162 +
	}
163 +
	if remote == "" {
164 +
		fmt.Fprintln(os.Stderr, "remote URL not set (use -r, SIPP_REMOTE_URL, or `sipp auth`)")
165 +
		os.Exit(2)
166 +
	}
167 +
168 +
	data, err := os.ReadFile(file)
169 +
	if err != nil {
170 +
		fmt.Fprintln(os.Stderr, err)
171 +
		os.Exit(1)
172 +
	}
173 +
	body, _ := json.Marshal(map[string]string{
174 +
		"name":    filepath.Base(file),
175 +
		"content": string(data),
176 +
	})
177 +
	req, err := http.NewRequest(http.MethodPost, strings.TrimRight(remote, "/")+"/api/snippets", bytes.NewReader(body))
178 +
	if err != nil {
179 +
		fmt.Fprintln(os.Stderr, err)
180 +
		os.Exit(1)
181 +
	}
182 +
	req.Header.Set("Content-Type", "application/json")
183 +
	if apiKey != "" {
184 +
		req.Header.Set("x-api-key", apiKey)
185 +
	}
186 +
	resp, err := http.DefaultClient.Do(req)
187 +
	if err != nil {
188 +
		fmt.Fprintln(os.Stderr, err)
189 +
		os.Exit(1)
190 +
	}
191 +
	defer resp.Body.Close()
192 +
	respBody, _ := io.ReadAll(resp.Body)
193 +
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
194 +
		fmt.Fprintf(os.Stderr, "server returned %s: %s\n", resp.Status, string(respBody))
195 +
		os.Exit(1)
196 +
	}
197 +
	var s server.Snippet
198 +
	if err := json.Unmarshal(respBody, &s); err != nil {
199 +
		fmt.Fprintln(os.Stderr, "could not parse response:", err)
200 +
		os.Exit(1)
201 +
	}
202 +
	fmt.Println(strings.TrimRight(remote, "/") + "/s/" + s.ShortID)
203 +
}
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) +59 −0
1 +
module github.com/stevedylandev/andromeda/apps/sipp-go
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/BurntSushi/toml v1.6.0
7 +
	github.com/alecthomas/chroma/v2 v2.14.0
8 +
	github.com/atotto/clipboard v0.1.4
9 +
	github.com/charmbracelet/bubbles v1.0.0
10 +
	github.com/charmbracelet/bubbletea v1.3.10
11 +
	github.com/charmbracelet/lipgloss v1.1.0
12 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
13 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
14 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
15 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
16 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
17 +
)
18 +
19 +
require (
20 +
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
21 +
	github.com/charmbracelet/colorprofile v0.4.1 // indirect
22 +
	github.com/charmbracelet/x/ansi v0.11.6 // indirect
23 +
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
24 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
25 +
	github.com/clipperhouse/displaywidth v0.9.0 // indirect
26 +
	github.com/clipperhouse/stringish v0.1.1 // indirect
27 +
	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
28 +
	github.com/dlclark/regexp2 v1.11.0 // indirect
29 +
	github.com/dustin/go-humanize v1.0.1 // indirect
30 +
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
31 +
	github.com/google/uuid v1.6.0 // indirect
32 +
	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
33 +
	github.com/mattn/go-isatty v0.0.20 // indirect
34 +
	github.com/mattn/go-localereader v0.0.1 // indirect
35 +
	github.com/mattn/go-runewidth v0.0.19 // indirect
36 +
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
37 +
	github.com/muesli/cancelreader v0.2.2 // indirect
38 +
	github.com/muesli/termenv v0.16.0 // indirect
39 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
40 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
41 +
	github.com/rivo/uniseg v0.4.7 // indirect
42 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
43 +
	golang.org/x/crypto v0.39.0 // indirect
44 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
45 +
	golang.org/x/sys v0.38.0 // indirect
46 +
	golang.org/x/text v0.26.0 // indirect
47 +
	modernc.org/libc v1.65.7 // indirect
48 +
	modernc.org/mathutil v1.7.1 // indirect
49 +
	modernc.org/memory v1.11.0 // indirect
50 +
	modernc.org/sqlite v1.37.1 // indirect
51 +
)
52 +
53 +
replace (
54 +
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
55 +
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
56 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
57 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
58 +
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
59 +
)
apps/sipp-go/go.sum (added) +112 −0
1 +
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 +
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 +
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4 +
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
5 +
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
6 +
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
7 +
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
8 +
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
9 +
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
10 +
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11 +
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
12 +
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
13 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
14 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
15 +
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
16 +
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
17 +
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
18 +
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
19 +
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
20 +
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
21 +
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
22 +
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
23 +
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
24 +
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
25 +
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
26 +
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
27 +
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
28 +
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
29 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
30 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
31 +
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
32 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
33 +
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
34 +
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
35 +
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
36 +
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
37 +
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
38 +
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
39 +
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
40 +
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
41 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
42 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
43 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
44 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
45 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
46 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
47 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
48 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
49 +
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
50 +
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
51 +
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
52 +
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
53 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
54 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
55 +
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
56 +
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
57 +
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
58 +
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
59 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
60 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
61 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
62 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
63 +
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
64 +
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
65 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
66 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
67 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
68 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
69 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
70 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
71 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
72 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
73 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
74 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
75 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
76 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
77 +
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
78 +
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
79 +
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
80 +
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
81 +
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
82 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
83 +
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
84 +
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
85 +
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
86 +
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
87 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
88 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
89 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
90 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
91 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
92 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
93 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
94 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
95 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
96 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
97 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
98 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
99 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
100 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
101 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
102 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
103 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
104 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
105 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
106 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
107 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
108 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
109 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
110 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
111 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
112 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/sipp-go/internal/store/store.go (added) +100 −0
1 +
package store
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
9 +
)
10 +
11 +
type Snippet struct {
12 +
	ID      int64  `json:"id"`
13 +
	ShortID string `json:"short_id"`
14 +
	Content string `json:"content"`
15 +
	Name    string `json:"name"`
16 +
}
17 +
18 +
type SnippetInput struct {
19 +
	Name    string `json:"name"`
20 +
	Content string `json:"content"`
21 +
}
22 +
23 +
const schema = `
24 +
CREATE TABLE IF NOT EXISTS snippets (
25 +
    id INTEGER PRIMARY KEY AUTOINCREMENT,
26 +
    short_id TEXT NOT NULL UNIQUE,
27 +
    content TEXT NOT NULL,
28 +
    name TEXT NOT NULL
29 +
);
30 +
`
31 +
32 +
func Open(path string) (*sql.DB, error) {
33 +
	return sqlite.Open(path, schema)
34 +
}
35 +
36 +
func scanSnippet(s interface{ Scan(...any) error }) (*Snippet, error) {
37 +
	var sn Snippet
38 +
	err := s.Scan(&sn.ID, &sn.ShortID, &sn.Content, &sn.Name)
39 +
	if errors.Is(err, sql.ErrNoRows) {
40 +
		return nil, nil
41 +
	}
42 +
	if err != nil {
43 +
		return nil, err
44 +
	}
45 +
	return &sn, nil
46 +
}
47 +
48 +
func Create(db *sql.DB, name, content string) (*Snippet, error) {
49 +
	shortID, err := auth.GenerateShortID(10)
50 +
	if err != nil {
51 +
		return nil, err
52 +
	}
53 +
	res, err := db.Exec(`INSERT INTO snippets (short_id, content, name) VALUES (?, ?, ?)`, shortID, content, name)
54 +
	if err != nil {
55 +
		return nil, err
56 +
	}
57 +
	id, _ := res.LastInsertId()
58 +
	return &Snippet{ID: id, ShortID: shortID, Content: content, Name: name}, nil
59 +
}
60 +
61 +
func GetByShortID(db *sql.DB, shortID string) (*Snippet, error) {
62 +
	return scanSnippet(db.QueryRow(`SELECT id, short_id, content, name FROM snippets WHERE short_id = ?`, shortID))
63 +
}
64 +
65 +
func List(db *sql.DB) ([]Snippet, error) {
66 +
	rows, err := db.Query(`SELECT id, short_id, content, name FROM snippets ORDER BY id DESC`)
67 +
	if err != nil {
68 +
		return nil, err
69 +
	}
70 +
	defer rows.Close()
71 +
	out := []Snippet{}
72 +
	for rows.Next() {
73 +
		s, err := scanSnippet(rows)
74 +
		if err != nil {
75 +
			return nil, err
76 +
		}
77 +
		out = append(out, *s)
78 +
	}
79 +
	return out, rows.Err()
80 +
}
81 +
82 +
func DeleteByShortID(db *sql.DB, shortID string) (bool, error) {
83 +
	res, err := db.Exec(`DELETE FROM snippets WHERE short_id = ?`, shortID)
84 +
	if err != nil {
85 +
		return false, err
86 +
	}
87 +
	n, _ := res.RowsAffected()
88 +
	return n > 0, nil
89 +
}
90 +
91 +
func UpdateByShortID(db *sql.DB, shortID, name, content string) (*Snippet, error) {
92 +
	res, err := db.Exec(`UPDATE snippets SET name = ?, content = ? WHERE short_id = ?`, name, content, shortID)
93 +
	if err != nil {
94 +
		return nil, err
95 +
	}
96 +
	if n, _ := res.RowsAffected(); n == 0 {
97 +
		return nil, nil
98 +
	}
99 +
	return GetByShortID(db, shortID)
100 +
}
apps/sipp-go/server/server.go (added) +408 −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 +
	"html/template"
10 +
	"io"
11 +
	"log"
12 +
	"log/slog"
13 +
	"net/http"
14 +
	"net/url"
15 +
	"os"
16 +
	"strconv"
17 +
	"strings"
18 +
19 +
	"github.com/alecthomas/chroma/v2"
20 +
	"github.com/alecthomas/chroma/v2/formatters/html"
21 +
	"github.com/alecthomas/chroma/v2/lexers"
22 +
	"github.com/alecthomas/chroma/v2/styles"
23 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/internal/store"
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 +
)
29 +
30 +
//go:embed templates/*.html static/*
31 +
var appFS embed.FS
32 +
33 +
type Snippet = store.Snippet
34 +
35 +
type App struct {
36 +
	DB              *sql.DB
37 +
	Log             *slog.Logger
38 +
	Templates       *template.Template
39 +
	Sessions        *auth.Store
40 +
	APIKey          string
41 +
	BaseURL         string
42 +
	CookieSecure    bool
43 +
	AuthEndpoints   map[string]bool
44 +
	MaxContentSize  int
45 +
}
46 +
47 +
var (
48 +
	createSnippet         = store.Create
49 +
	getSnippetByShortID   = store.GetByShortID
50 +
	getAllSnippets        = store.List
51 +
	deleteSnippetByShortID = store.DeleteByShortID
52 +
	updateSnippetByShortID = store.UpdateByShortID
53 +
)
54 +
55 +
func highlight(name, content string) string {
56 +
	ext := ""
57 +
	if i := strings.LastIndex(name, "."); i >= 0 && i < len(name)-1 {
58 +
		ext = strings.ToLower(name[i+1:])
59 +
	}
60 +
	switch ext {
61 +
	case "ts", "tsx", "jsx":
62 +
		ext = "js"
63 +
	}
64 +
	var lexer chroma.Lexer
65 +
	if ext != "" {
66 +
		lexer = lexers.MatchMimeType("text/" + ext)
67 +
		if lexer == nil {
68 +
			lexer = lexers.Get(ext)
69 +
		}
70 +
	}
71 +
	if lexer == nil {
72 +
		lexer = lexers.Analyse(content)
73 +
	}
74 +
	if lexer == nil {
75 +
		lexer = lexers.Fallback
76 +
	}
77 +
	style := styles.Get("monokai")
78 +
	if style == nil {
79 +
		style = styles.Fallback
80 +
	}
81 +
	formatter := html.New(html.Standalone(false), html.WithClasses(false))
82 +
	iterator, err := lexer.Tokenise(nil, content)
83 +
	if err != nil {
84 +
		escaped := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(content)
85 +
		return "<pre>" + escaped + "</pre>"
86 +
	}
87 +
	var buf bytes.Buffer
88 +
	if err := formatter.Format(&buf, style, iterator); err != nil {
89 +
		escaped := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(content)
90 +
		return "<pre>" + escaped + "</pre>"
91 +
	}
92 +
	return buf.String()
93 +
}
94 +
95 +
type indexPageData struct{ BaseURL string }
96 +
type adminPageData struct {
97 +
	BaseURL  string
98 +
	Snippets []Snippet
99 +
}
100 +
type loginPageData struct {
101 +
	Error string
102 +
	Next  string
103 +
}
104 +
type snippetPageData struct {
105 +
	BaseURL            string
106 +
	Name               string
107 +
	Content            string
108 +
	HighlightedContent template.HTML
109 +
}
110 +
111 +
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
112 +
	web.Render(a.Templates, w, "index.html", indexPageData{BaseURL: a.BaseURL}, a.Log)
113 +
}
114 +
115 +
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
116 +
	snippets, err := getAllSnippets(a.DB)
117 +
	if err != nil {
118 +
		http.Error(w, "Server error", http.StatusInternalServerError)
119 +
		return
120 +
	}
121 +
	web.Render(a.Templates, w, "admin.html", adminPageData{BaseURL: a.BaseURL, Snippets: snippets}, a.Log)
122 +
}
123 +
124 +
func (a *App) loginGet(w http.ResponseWriter, r *http.Request) {
125 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error"), Next: r.URL.Query().Get("next")}, a.Log)
126 +
}
127 +
128 +
func (a *App) loginPost(w http.ResponseWriter, r *http.Request) {
129 +
	next := r.URL.Query().Get("next")
130 +
	if next == "" {
131 +
		next = "/admin"
132 +
	}
133 +
	if err := r.ParseForm(); err != nil {
134 +
		http.Redirect(w, r, "/admin/login?error=Bad+request", http.StatusSeeOther)
135 +
		return
136 +
	}
137 +
	if a.APIKey == "" {
138 +
		http.Redirect(w, r, "/admin/login?error=No+API+key+configured", http.StatusSeeOther)
139 +
		return
140 +
	}
141 +
	if !auth.SecureEqual(r.FormValue("api_key"), a.APIKey) {
142 +
		http.Redirect(w, r, "/admin/login?error=Invalid+API+key&next="+url.QueryEscape(next), http.StatusSeeOther)
143 +
		return
144 +
	}
145 +
	token, err := a.Sessions.Create()
146 +
	if err != nil {
147 +
		http.Redirect(w, r, "/admin/login?error=Server+error", http.StatusSeeOther)
148 +
		return
149 +
	}
150 +
	a.Sessions.PruneExpired()
151 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
152 +
	target := "/admin"
153 +
	if strings.HasPrefix(next, "/") {
154 +
		target = next
155 +
	}
156 +
	http.Redirect(w, r, target, http.StatusSeeOther)
157 +
}
158 +
159 +
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
160 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
161 +
		a.Sessions.Delete(c.Value)
162 +
	}
163 +
	http.SetCookie(w, a.Sessions.ClearCookie())
164 +
	http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
165 +
}
166 +
167 +
func (a *App) adminDeleteSnippet(w http.ResponseWriter, r *http.Request) {
168 +
	_, _ = deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
169 +
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
170 +
}
171 +
172 +
func isCLIUserAgent(r *http.Request) bool {
173 +
	ua := strings.ToLower(r.Header.Get("User-Agent"))
174 +
	return strings.HasPrefix(ua, "curl/") || strings.HasPrefix(ua, "wget/") || strings.HasPrefix(ua, "httpie/")
175 +
}
176 +
177 +
func (a *App) viewSnippet(w http.ResponseWriter, r *http.Request) {
178 +
	snippet, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
179 +
	if err != nil {
180 +
		http.Error(w, "Internal server error", http.StatusInternalServerError)
181 +
		return
182 +
	}
183 +
	if snippet == nil {
184 +
		http.Error(w, "Snippet not found", http.StatusNotFound)
185 +
		return
186 +
	}
187 +
	if isCLIUserAgent(r) {
188 +
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
189 +
		_, _ = w.Write([]byte(snippet.Content))
190 +
		return
191 +
	}
192 +
	highlighted := highlight(snippet.Name, snippet.Content)
193 +
	web.Render(a.Templates, w, "snippet.html", snippetPageData{
194 +
		BaseURL:            a.BaseURL,
195 +
		Name:               snippet.Name,
196 +
		Content:            snippet.Content,
197 +
		HighlightedContent: template.HTML(highlighted),
198 +
	}, a.Log)
199 +
}
200 +
201 +
func (a *App) createSnippetForm(w http.ResponseWriter, r *http.Request) {
202 +
	if err := r.ParseForm(); err != nil {
203 +
		http.Error(w, "Bad request", http.StatusBadRequest)
204 +
		return
205 +
	}
206 +
	content := r.FormValue("content")
207 +
	if len(content) > a.MaxContentSize {
208 +
		http.Error(w, "Content too large", http.StatusRequestEntityTooLarge)
209 +
		return
210 +
	}
211 +
	sn, err := createSnippet(a.DB, r.FormValue("name"), content)
212 +
	if err != nil {
213 +
		http.Error(w, "Server error", http.StatusInternalServerError)
214 +
		return
215 +
	}
216 +
	http.Redirect(w, r, "/s/"+sn.ShortID, http.StatusSeeOther)
217 +
}
218 +
219 +
func (a *App) requireAPIKey(next http.HandlerFunc) http.HandlerFunc {
220 +
	return func(w http.ResponseWriter, r *http.Request) {
221 +
		if a.APIKey == "" {
222 +
			web.WriteError(w, http.StatusForbidden, "No API key configured on server")
223 +
			return
224 +
		}
225 +
		if auth.SecureEqual(r.Header.Get("x-api-key"), a.APIKey) {
226 +
			next(w, r)
227 +
			return
228 +
		}
229 +
		if a.Sessions.HasValid(r) {
230 +
			next(w, r)
231 +
			return
232 +
		}
233 +
		web.WriteError(w, http.StatusUnauthorized, "Invalid or missing API key")
234 +
	}
235 +
}
236 +
237 +
func (a *App) apiList(w http.ResponseWriter, r *http.Request) {
238 +
	snippets, err := getAllSnippets(a.DB)
239 +
	if err != nil {
240 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
241 +
		return
242 +
	}
243 +
	web.WriteJSON(w, http.StatusOK, snippets)
244 +
}
245 +
246 +
func (a *App) apiGet(w http.ResponseWriter, r *http.Request) {
247 +
	s, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
248 +
	if err != nil {
249 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
250 +
		return
251 +
	}
252 +
	if s == nil {
253 +
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
254 +
		return
255 +
	}
256 +
	web.WriteJSON(w, http.StatusOK, s)
257 +
}
258 +
259 +
type apiCreateBody struct {
260 +
	Name    string `json:"name"`
261 +
	Content string `json:"content"`
262 +
}
263 +
264 +
func (a *App) apiCreate(w http.ResponseWriter, r *http.Request) {
265 +
	var body apiCreateBody
266 +
	if !web.DecodeJSON(w, r, &body) {
267 +
		return
268 +
	}
269 +
	if len(body.Content) > a.MaxContentSize {
270 +
		web.WriteError(w, http.StatusRequestEntityTooLarge, "Content too large. Maximum size is "+strconv.Itoa(a.MaxContentSize)+" bytes")
271 +
		return
272 +
	}
273 +
	s, err := createSnippet(a.DB, body.Name, body.Content)
274 +
	if err != nil {
275 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
276 +
		return
277 +
	}
278 +
	web.WriteJSON(w, http.StatusCreated, s)
279 +
}
280 +
281 +
func (a *App) apiUpdate(w http.ResponseWriter, r *http.Request) {
282 +
	var body apiCreateBody
283 +
	if !web.DecodeJSON(w, r, &body) {
284 +
		return
285 +
	}
286 +
	if len(body.Content) > a.MaxContentSize {
287 +
		web.WriteError(w, http.StatusRequestEntityTooLarge, "Content too large")
288 +
		return
289 +
	}
290 +
	s, err := updateSnippetByShortID(a.DB, r.PathValue("short_id"), body.Name, body.Content)
291 +
	if err != nil {
292 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
293 +
		return
294 +
	}
295 +
	if s == nil {
296 +
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
297 +
		return
298 +
	}
299 +
	web.WriteJSON(w, http.StatusOK, s)
300 +
}
301 +
302 +
func (a *App) apiDelete(w http.ResponseWriter, r *http.Request) {
303 +
	ok, err := deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
304 +
	if err != nil {
305 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
306 +
		return
307 +
	}
308 +
	if !ok {
309 +
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
310 +
		return
311 +
	}
312 +
	web.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true})
313 +
}
314 +
315 +
func (a *App) requiresAuth(name string) bool {
316 +
	return a.AuthEndpoints["all"] || a.AuthEndpoints[name]
317 +
}
318 +
319 +
func (a *App) wrapIfAuth(name string, h http.HandlerFunc) http.HandlerFunc {
320 +
	if a.requiresAuth(name) {
321 +
		return a.requireAPIKey(h)
322 +
	}
323 +
	return h
324 +
}
325 +
326 +
func parseAuthEndpoints(raw string) map[string]bool {
327 +
	out := map[string]bool{}
328 +
	if strings.EqualFold(strings.TrimSpace(raw), "none") {
329 +
		return out
330 +
	}
331 +
	if raw == "" {
332 +
		out["api_delete"] = true
333 +
		out["api_list"] = true
334 +
		out["api_update"] = true
335 +
		return out
336 +
	}
337 +
	for _, p := range strings.Split(raw, ",") {
338 +
		if v := strings.ToLower(strings.TrimSpace(p)); v != "" {
339 +
			out[v] = true
340 +
		}
341 +
	}
342 +
	return out
343 +
}
344 +
345 +
// Run starts the sipp web server.
346 +
func Run(host string, port int) error {
347 +
	config.LoadDotEnv(".env")
348 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
349 +
350 +
	dbPath := config.Getenv("SIPP_DB_PATH", "sipp.sqlite")
351 +
	db, err := store.Open(dbPath)
352 +
	if err != nil {
353 +
		return err
354 +
	}
355 +
356 +
	apiKey := os.Getenv("SIPP_API_KEY")
357 +
	authEndpoints := parseAuthEndpoints(os.Getenv("SIPP_AUTH_ENDPOINTS"))
358 +
	maxSize := config.GetenvInt("SIPP_MAX_CONTENT_SIZE", 512000)
359 +
	baseURL := strings.TrimRight(config.Getenv("BASE_URL", "http://localhost:3000"), "/")
360 +
	cookieSecure := config.GetenvBool("SIPP_COOKIE_SECURE", false)
361 +
362 +
	if len(authEndpoints) > 0 && apiKey == "" {
363 +
		logger.Warn("SIPP_AUTH_ENDPOINTS set but SIPP_API_KEY not configured")
364 +
	}
365 +
366 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: cookieSecure}
367 +
	if err := sessions.EnsureSchema(); err != nil {
368 +
		return err
369 +
	}
370 +
	sessions.PruneExpired()
371 +
372 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
373 +
374 +
	app := &App{
375 +
		DB: db, Log: logger, Templates: tmpl, Sessions: sessions,
376 +
		APIKey: apiKey, BaseURL: baseURL, CookieSecure: cookieSecure,
377 +
		AuthEndpoints: authEndpoints, MaxContentSize: maxSize,
378 +
	}
379 +
380 +
	mux := http.NewServeMux()
381 +
	mux.HandleFunc("GET /", app.indexHandler)
382 +
	mux.HandleFunc("GET /admin", app.Sessions.RequireSession("/admin/login", app.adminHandler))
383 +
	mux.HandleFunc("GET /admin/login", app.loginGet)
384 +
	mux.HandleFunc("POST /admin/login", app.loginPost)
385 +
	mux.HandleFunc("POST /admin/logout", app.logout)
386 +
	mux.HandleFunc("POST /admin/snippets/{short_id}/delete", app.Sessions.RequireSession("/admin/login", app.adminDeleteSnippet))
387 +
	mux.HandleFunc("GET /s/{short_id}", app.viewSnippet)
388 +
	mux.HandleFunc("POST /snippets", app.createSnippetForm)
389 +
390 +
	mux.HandleFunc("GET /api/snippets", app.wrapIfAuth("api_list", app.apiList))
391 +
	mux.HandleFunc("POST /api/snippets", app.wrapIfAuth("api_create", app.apiCreate))
392 +
	mux.HandleFunc("GET /api/snippets/{short_id}", app.wrapIfAuth("api_get", app.apiGet))
393 +
	mux.HandleFunc("PUT /api/snippets/{short_id}", app.wrapIfAuth("api_update", app.apiUpdate))
394 +
	mux.HandleFunc("DELETE /api/snippets/{short_id}", app.wrapIfAuth("api_delete", app.apiDelete))
395 +
396 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
397 +
	darkmatter.Mount(mux, "/assets")
398 +
399 +
	addr := host + ":" + strconv.Itoa(port)
400 +
	logger.Info("sipp-go server running", "addr", addr)
401 +
	return http.ListenAndServe(addr, mux)
402 +
}
403 +
404 +
// Silence unused import warnings; keep these for forward use.
405 +
var _ = json.Marshal
406 +
var _ = bytes.NewReader
407 +
var _ io.Reader = (*bytes.Reader)(nil)
408 +
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}}
apps/sipp-go/tui/backend.go (added) +208 −0
1 +
package tui
2 +
3 +
import (
4 +
	"bytes"
5 +
	"database/sql"
6 +
	"encoding/json"
7 +
	"fmt"
8 +
	"io"
9 +
	"net/http"
10 +
	"os"
11 +
	"strings"
12 +
	"time"
13 +
14 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/internal/store"
15 +
	"github.com/stevedylandev/andromeda/crates-go/config"
16 +
)
17 +
18 +
type Snippet = store.Snippet
19 +
20 +
type Backend interface {
21 +
	List() ([]Snippet, error)
22 +
	Get(shortID string) (*Snippet, error)
23 +
	Create(name, content string) (*Snippet, error)
24 +
	Update(shortID, name, content string) (*Snippet, error)
25 +
	Delete(shortID string) (bool, error)
26 +
	RemoteURL() string
27 +
	Close() error
28 +
}
29 +
30 +
type LocalBackend struct {
31 +
	DB *sql.DB
32 +
}
33 +
34 +
func (b *LocalBackend) List() ([]Snippet, error)                      { return store.List(b.DB) }
35 +
func (b *LocalBackend) Get(s string) (*Snippet, error)                { return store.GetByShortID(b.DB, s) }
36 +
func (b *LocalBackend) Create(n, c string) (*Snippet, error)          { return store.Create(b.DB, n, c) }
37 +
func (b *LocalBackend) Update(s, n, c string) (*Snippet, error)       { return store.UpdateByShortID(b.DB, s, n, c) }
38 +
func (b *LocalBackend) Delete(s string) (bool, error)                 { return store.DeleteByShortID(b.DB, s) }
39 +
func (b *LocalBackend) RemoteURL() string                             { return "" }
40 +
func (b *LocalBackend) Close() error                                  { return b.DB.Close() }
41 +
42 +
type RemoteBackend struct {
43 +
	BaseURL string
44 +
	APIKey  string
45 +
	Client  *http.Client
46 +
}
47 +
48 +
func (r *RemoteBackend) RemoteURL() string { return r.BaseURL }
49 +
func (r *RemoteBackend) Close() error      { return nil }
50 +
51 +
func (r *RemoteBackend) do(method, path string, body any, out any) error {
52 +
	var reader io.Reader
53 +
	if body != nil {
54 +
		buf, err := json.Marshal(body)
55 +
		if err != nil {
56 +
			return err
57 +
		}
58 +
		reader = bytes.NewReader(buf)
59 +
	}
60 +
	req, err := http.NewRequest(method, strings.TrimRight(r.BaseURL, "/")+path, reader)
61 +
	if err != nil {
62 +
		return err
63 +
	}
64 +
	if body != nil {
65 +
		req.Header.Set("Content-Type", "application/json")
66 +
	}
67 +
	if r.APIKey != "" {
68 +
		req.Header.Set("x-api-key", r.APIKey)
69 +
	}
70 +
	resp, err := r.Client.Do(req)
71 +
	if err != nil {
72 +
		return err
73 +
	}
74 +
	defer resp.Body.Close()
75 +
	if resp.StatusCode == http.StatusNotFound {
76 +
		return errNotFound
77 +
	}
78 +
	if resp.StatusCode >= 400 {
79 +
		b, _ := io.ReadAll(resp.Body)
80 +
		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
81 +
	}
82 +
	if out == nil || resp.StatusCode == http.StatusNoContent {
83 +
		return nil
84 +
	}
85 +
	return json.NewDecoder(resp.Body).Decode(out)
86 +
}
87 +
88 +
var errNotFound = fmt.Errorf("not found")
89 +
90 +
func (r *RemoteBackend) List() ([]Snippet, error) {
91 +
	var out []Snippet
92 +
	if err := r.do("GET", "/api/snippets", nil, &out); err != nil {
93 +
		return nil, err
94 +
	}
95 +
	return out, nil
96 +
}
97 +
98 +
func (r *RemoteBackend) Get(shortID string) (*Snippet, error) {
99 +
	var s Snippet
100 +
	if err := r.do("GET", "/api/snippets/"+shortID, nil, &s); err != nil {
101 +
		if err == errNotFound {
102 +
			return nil, nil
103 +
		}
104 +
		return nil, err
105 +
	}
106 +
	return &s, nil
107 +
}
108 +
109 +
func (r *RemoteBackend) Create(name, content string) (*Snippet, error) {
110 +
	var s Snippet
111 +
	if err := r.do("POST", "/api/snippets", store.SnippetInput{Name: name, Content: content}, &s); err != nil {
112 +
		return nil, err
113 +
	}
114 +
	return &s, nil
115 +
}
116 +
117 +
func (r *RemoteBackend) Update(shortID, name, content string) (*Snippet, error) {
118 +
	var s Snippet
119 +
	if err := r.do("PUT", "/api/snippets/"+shortID, store.SnippetInput{Name: name, Content: content}, &s); err != nil {
120 +
		if err == errNotFound {
121 +
			return nil, nil
122 +
		}
123 +
		return nil, err
124 +
	}
125 +
	return &s, nil
126 +
}
127 +
128 +
func (r *RemoteBackend) Delete(shortID string) (bool, error) {
129 +
	if err := r.do("DELETE", "/api/snippets/"+shortID, nil, nil); err != nil {
130 +
		if err == errNotFound {
131 +
			return false, nil
132 +
		}
133 +
		return false, err
134 +
	}
135 +
	return true, nil
136 +
}
137 +
138 +
type Options struct {
139 +
	RemoteURL string
140 +
	APIKey    string
141 +
	DBPath    string
142 +
}
143 +
144 +
func ParseArgs(args []string) Options {
145 +
	opts := Options{}
146 +
	for i := 0; i < len(args); i++ {
147 +
		a := args[i]
148 +
		switch {
149 +
		case (a == "--remote" || a == "-r") && i+1 < len(args):
150 +
			opts.RemoteURL = args[i+1]
151 +
			i++
152 +
		case strings.HasPrefix(a, "--remote="):
153 +
			opts.RemoteURL = strings.TrimPrefix(a, "--remote=")
154 +
		case (a == "--api-key" || a == "-k") && i+1 < len(args):
155 +
			opts.APIKey = args[i+1]
156 +
			i++
157 +
		case strings.HasPrefix(a, "--api-key="):
158 +
			opts.APIKey = strings.TrimPrefix(a, "--api-key=")
159 +
		case a == "--db" && i+1 < len(args):
160 +
			opts.DBPath = args[i+1]
161 +
			i++
162 +
		}
163 +
	}
164 +
	return opts
165 +
}
166 +
167 +
func ResolveBackend(opts Options) (Backend, error) {
168 +
	cfg, _ := LoadConfig()
169 +
170 +
	remoteURL := opts.RemoteURL
171 +
	if remoteURL == "" {
172 +
		remoteURL = os.Getenv("SIPP_REMOTE_URL")
173 +
	}
174 +
	apiKey := opts.APIKey
175 +
	if apiKey == "" {
176 +
		apiKey = os.Getenv("SIPP_API_KEY")
177 +
	}
178 +
	if apiKey == "" {
179 +
		apiKey = cfg.APIKey
180 +
	}
181 +
182 +
	dbPath := opts.DBPath
183 +
	if dbPath == "" {
184 +
		dbPath = config.Getenv("SIPP_DB_PATH", "sipp.sqlite")
185 +
	}
186 +
187 +
	useRemote := remoteURL != ""
188 +
	if !useRemote {
189 +
		if _, err := os.Stat(dbPath); err != nil && cfg.RemoteURL != "" {
190 +
			remoteURL = cfg.RemoteURL
191 +
			useRemote = true
192 +
		}
193 +
	}
194 +
195 +
	if useRemote {
196 +
		return &RemoteBackend{
197 +
			BaseURL: remoteURL,
198 +
			APIKey:  apiKey,
199 +
			Client:  &http.Client{Timeout: 15 * time.Second},
200 +
		}, nil
201 +
	}
202 +
203 +
	db, err := store.Open(dbPath)
204 +
	if err != nil {
205 +
		return nil, err
206 +
	}
207 +
	return &LocalBackend{DB: db}, nil
208 +
}
apps/sipp-go/tui/config.go (added) +56 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
7 +
	"github.com/BurntSushi/toml"
8 +
)
9 +
10 +
type Config struct {
11 +
	RemoteURL string `toml:"remote_url"`
12 +
	APIKey    string `toml:"api_key"`
13 +
}
14 +
15 +
func ConfigPath() (string, error) {
16 +
	dir, err := os.UserConfigDir()
17 +
	if err != nil {
18 +
		return "", err
19 +
	}
20 +
	return filepath.Join(dir, "sipp", "config.toml"), nil
21 +
}
22 +
23 +
func LoadConfig() (Config, error) {
24 +
	var cfg Config
25 +
	path, err := ConfigPath()
26 +
	if err != nil {
27 +
		return cfg, err
28 +
	}
29 +
	data, err := os.ReadFile(path)
30 +
	if err != nil {
31 +
		if os.IsNotExist(err) {
32 +
			return cfg, nil
33 +
		}
34 +
		return cfg, err
35 +
	}
36 +
	if err := toml.Unmarshal(data, &cfg); err != nil {
37 +
		return cfg, err
38 +
	}
39 +
	return cfg, nil
40 +
}
41 +
42 +
func SaveConfig(cfg Config) error {
43 +
	path, err := ConfigPath()
44 +
	if err != nil {
45 +
		return err
46 +
	}
47 +
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 +
		return err
49 +
	}
50 +
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
51 +
	if err != nil {
52 +
		return err
53 +
	}
54 +
	defer f.Close()
55 +
	return toml.NewEncoder(f).Encode(cfg)
56 +
}
apps/sipp-go/tui/editor.go (added) +59 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"os/exec"
7 +
	"path/filepath"
8 +
	"runtime"
9 +
10 +
	tea "github.com/charmbracelet/bubbletea"
11 +
)
12 +
13 +
func openExternalEditor(shortID, name, content string) tea.Cmd {
14 +
	editor := os.Getenv("EDITOR")
15 +
	if editor == "" {
16 +
		return func() tea.Msg {
17 +
			return statusMsg{text: "$EDITOR not set", ok: false}
18 +
		}
19 +
	}
20 +
21 +
	base := name
22 +
	if base == "" {
23 +
		base = "snippet.txt"
24 +
	}
25 +
	tmp := filepath.Join(os.TempDir(), fmt.Sprintf("sipp-%s-%s", shortID, filepath.Base(base)))
26 +
	if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
27 +
		return func() tea.Msg {
28 +
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
29 +
		}
30 +
	}
31 +
32 +
	cmd := exec.Command(editor, tmp)
33 +
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
34 +
		defer os.Remove(tmp)
35 +
		if err != nil {
36 +
			return editorFinishedMsg{shortID: shortID, err: err}
37 +
		}
38 +
		b, rerr := os.ReadFile(tmp)
39 +
		if rerr != nil {
40 +
			return editorFinishedMsg{shortID: shortID, err: rerr}
41 +
		}
42 +
		return editorFinishedMsg{shortID: shortID, content: string(b)}
43 +
	})
44 +
}
45 +
46 +
func openURL(url string) error {
47 +
	var cmd *exec.Cmd
48 +
	switch runtime.GOOS {
49 +
	case "linux":
50 +
		cmd = exec.Command("xdg-open", url)
51 +
	case "darwin":
52 +
		cmd = exec.Command("open", url)
53 +
	case "windows":
54 +
		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
55 +
	default:
56 +
		return fmt.Errorf("unsupported platform %s", runtime.GOOS)
57 +
	}
58 +
	return cmd.Start()
59 +
}
apps/sipp-go/tui/highlight.go (added) +62 −0
1 +
package tui
2 +
3 +
import (
4 +
	"bytes"
5 +
	"strings"
6 +
7 +
	"github.com/alecthomas/chroma/v2"
8 +
	"github.com/alecthomas/chroma/v2/formatters"
9 +
	"github.com/alecthomas/chroma/v2/lexers"
10 +
	"github.com/alecthomas/chroma/v2/styles"
11 +
)
12 +
13 +
type highlighter struct {
14 +
	cache map[string]string
15 +
}
16 +
17 +
func newHighlighter() *highlighter {
18 +
	return &highlighter{cache: map[string]string{}}
19 +
}
20 +
21 +
func (h *highlighter) render(shortID, name, content string) string {
22 +
	if v, ok := h.cache[shortID]; ok {
23 +
		return v
24 +
	}
25 +
	out := highlightCode(name, content)
26 +
	h.cache[shortID] = out
27 +
	return out
28 +
}
29 +
30 +
func (h *highlighter) invalidate(shortID string) {
31 +
	delete(h.cache, shortID)
32 +
}
33 +
34 +
func highlightCode(name, content string) string {
35 +
	var lexer chroma.Lexer
36 +
	if name != "" {
37 +
		lexer = lexers.Match(name)
38 +
	}
39 +
	if lexer == nil {
40 +
		lexer = lexers.Analyse(content)
41 +
	}
42 +
	if lexer == nil {
43 +
		lexer = lexers.Fallback
44 +
	}
45 +
	style := styles.Get("monokai")
46 +
	if style == nil {
47 +
		style = styles.Fallback
48 +
	}
49 +
	formatter := formatters.Get("terminal256")
50 +
	if formatter == nil {
51 +
		formatter = formatters.Fallback
52 +
	}
53 +
	iter, err := lexer.Tokenise(nil, content)
54 +
	if err != nil {
55 +
		return content
56 +
	}
57 +
	var buf bytes.Buffer
58 +
	if err := formatter.Format(&buf, style, iter); err != nil {
59 +
		return content
60 +
	}
61 +
	return strings.TrimRight(buf.String(), "\n")
62 +
}
apps/sipp-go/tui/keys.go (added) +61 −0
1 +
package tui
2 +
3 +
import "github.com/charmbracelet/bubbles/key"
4 +
5 +
type keyMap struct {
6 +
	Up          key.Binding
7 +
	Down        key.Binding
8 +
	Open        key.Binding
9 +
	Back        key.Binding
10 +
	Quit        key.Binding
11 +
	Create      key.Binding
12 +
	Edit        key.Binding
13 +
	ExtEdit     key.Binding
14 +
	Delete      key.Binding
15 +
	Copy        key.Binding
16 +
	CopyLink    key.Binding
17 +
	OpenBrowser key.Binding
18 +
	Search      key.Binding
19 +
	Refresh     key.Binding
20 +
	Help        key.Binding
21 +
	Save        key.Binding
22 +
	SwitchField key.Binding
23 +
	Cancel      key.Binding
24 +
}
25 +
26 +
func defaultKeys() keyMap {
27 +
	return keyMap{
28 +
		Up:          key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
29 +
		Down:        key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
30 +
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
31 +
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
32 +
		Quit:        key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
33 +
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
34 +
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
35 +
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
36 +
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
37 +
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
38 +
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
39 +
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
40 +
		Search:      key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
41 +
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
42 +
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
43 +
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
44 +
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
45 +
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
46 +
	}
47 +
}
48 +
49 +
func (k keyMap) ShortHelp() []key.Binding {
50 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit}
51 +
}
52 +
53 +
func (k keyMap) FullHelp() [][]key.Binding {
54 +
	return [][]key.Binding{
55 +
		{k.Up, k.Down, k.Open, k.Back},
56 +
		{k.Create, k.Edit, k.ExtEdit, k.Delete},
57 +
		{k.Copy, k.CopyLink, k.OpenBrowser, k.Search},
58 +
		{k.Refresh, k.Help, k.Save, k.SwitchField},
59 +
		{k.Cancel, k.Quit},
60 +
	}
61 +
}
apps/sipp-go/tui/messages.go (added) +29 −0
1 +
package tui
2 +
3 +
type snippetsLoadedMsg struct {
4 +
	snippets []Snippet
5 +
	err      error
6 +
}
7 +
8 +
type snippetSavedMsg struct {
9 +
	snippet *Snippet
10 +
	err     error
11 +
}
12 +
13 +
type snippetDeletedMsg struct {
14 +
	shortID string
15 +
	err     error
16 +
}
17 +
18 +
type editorFinishedMsg struct {
19 +
	shortID string
20 +
	content string
21 +
	err     error
22 +
}
23 +
24 +
type statusMsg struct {
25 +
	text string
26 +
	ok   bool
27 +
}
28 +
29 +
type clearStatusMsg struct{}
apps/sipp-go/tui/model.go (added) +145 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
	"time"
6 +
7 +
	"github.com/charmbracelet/bubbles/help"
8 +
	"github.com/charmbracelet/bubbles/textarea"
9 +
	"github.com/charmbracelet/bubbles/textinput"
10 +
	"github.com/charmbracelet/bubbles/viewport"
11 +
	tea "github.com/charmbracelet/bubbletea"
12 +
)
13 +
14 +
type Focus int
15 +
16 +
const (
17 +
	FocusList Focus = iota
18 +
	FocusContent
19 +
	FocusCreateName
20 +
	FocusCreateContent
21 +
	FocusEditName
22 +
	FocusEditContent
23 +
	FocusSearch
24 +
)
25 +
26 +
type Model struct {
27 +
	backend  Backend
28 +
	isRemote bool
29 +
30 +
	snippets []Snippet
31 +
	filtered []int
32 +
	cursor   int
33 +
34 +
	focus         Focus
35 +
	showHelp      bool
36 +
	confirmDelete bool
37 +
38 +
	nameInput   textinput.Model
39 +
	contentArea textarea.Model
40 +
	searchInput textinput.Model
41 +
	contentVP   viewport.Model
42 +
	help        help.Model
43 +
	keys        keyMap
44 +
45 +
	highlighter *highlighter
46 +
47 +
	editShortID string
48 +
49 +
	status      string
50 +
	statusOK    bool
51 +
	statusUntil time.Time
52 +
53 +
	width, height int
54 +
	ready         bool
55 +
	loading       bool
56 +
}
57 +
58 +
func newModel(backend Backend) Model {
59 +
	ti := textinput.New()
60 +
	ti.Placeholder = "name.ext"
61 +
	ti.Prompt = ""
62 +
	ti.CharLimit = 200
63 +
64 +
	ta := textarea.New()
65 +
	ta.Placeholder = "Paste code..."
66 +
	ta.ShowLineNumbers = true
67 +
	ta.Prompt = ""
68 +
69 +
	si := textinput.New()
70 +
	si.Placeholder = "search names"
71 +
	si.Prompt = "/ "
72 +
73 +
	vp := viewport.New(0, 0)
74 +
75 +
	return Model{
76 +
		backend:     backend,
77 +
		isRemote:    backend.RemoteURL() != "",
78 +
		focus:       FocusList,
79 +
		nameInput:   ti,
80 +
		contentArea: ta,
81 +
		searchInput: si,
82 +
		contentVP:   vp,
83 +
		help:        help.New(),
84 +
		keys:        defaultKeys(),
85 +
		highlighter: newHighlighter(),
86 +
	}
87 +
}
88 +
89 +
func (m Model) Init() tea.Cmd {
90 +
	return loadSnippetsCmd(m.backend)
91 +
}
92 +
93 +
func (m *Model) visible() []Snippet {
94 +
	if m.filtered == nil {
95 +
		return m.snippets
96 +
	}
97 +
	out := make([]Snippet, 0, len(m.filtered))
98 +
	for _, i := range m.filtered {
99 +
		out = append(out, m.snippets[i])
100 +
	}
101 +
	return out
102 +
}
103 +
104 +
func (m *Model) current() *Snippet {
105 +
	list := m.visible()
106 +
	if m.cursor < 0 || m.cursor >= len(list) {
107 +
		return nil
108 +
	}
109 +
	return &list[m.cursor]
110 +
}
111 +
112 +
func (m *Model) applyFilter(q string) {
113 +
	q = strings.TrimSpace(strings.ToLower(q))
114 +
	if q == "" {
115 +
		m.filtered = nil
116 +
		if m.cursor >= len(m.snippets) {
117 +
			m.cursor = 0
118 +
		}
119 +
		return
120 +
	}
121 +
	idx := []int{}
122 +
	for i, s := range m.snippets {
123 +
		if strings.Contains(strings.ToLower(s.Name), q) || strings.Contains(strings.ToLower(s.ShortID), q) {
124 +
			idx = append(idx, i)
125 +
		}
126 +
	}
127 +
	m.filtered = idx
128 +
	if m.cursor >= len(idx) {
129 +
		m.cursor = 0
130 +
	}
131 +
}
132 +
133 +
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
134 +
	m.status = text
135 +
	m.statusOK = ok
136 +
	m.statusUntil = time.Now().Add(2 * time.Second)
137 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
138 +
}
139 +
140 +
func (m *Model) shareURL(shortID string) string {
141 +
	if m.backend.RemoteURL() == "" {
142 +
		return ""
143 +
	}
144 +
	return strings.TrimRight(m.backend.RemoteURL(), "/") + "/s/" + shortID
145 +
}
apps/sipp-go/tui/tui.go (added) +17 −0
1 +
package tui
2 +
3 +
import (
4 +
	tea "github.com/charmbracelet/bubbletea"
5 +
)
6 +
7 +
func Run(opts Options) error {
8 +
	backend, err := ResolveBackend(opts)
9 +
	if err != nil {
10 +
		return err
11 +
	}
12 +
	defer backend.Close()
13 +
14 +
	p := tea.NewProgram(newModel(backend), tea.WithAltScreen())
15 +
	_, err = p.Run()
16 +
	return err
17 +
}
apps/sipp-go/tui/update.go (added) +403 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	"github.com/atotto/clipboard"
7 +
	"github.com/charmbracelet/bubbles/key"
8 +
	tea "github.com/charmbracelet/bubbletea"
9 +
)
10 +
11 +
func loadSnippetsCmd(b Backend) tea.Cmd {
12 +
	return func() tea.Msg {
13 +
		list, err := b.List()
14 +
		return snippetsLoadedMsg{snippets: list, err: err}
15 +
	}
16 +
}
17 +
18 +
func saveSnippetCmd(b Backend, shortID, name, content string) tea.Cmd {
19 +
	return func() tea.Msg {
20 +
		var (
21 +
			s   *Snippet
22 +
			err error
23 +
		)
24 +
		if shortID == "" {
25 +
			s, err = b.Create(name, content)
26 +
		} else {
27 +
			s, err = b.Update(shortID, name, content)
28 +
		}
29 +
		return snippetSavedMsg{snippet: s, err: err}
30 +
	}
31 +
}
32 +
33 +
func deleteSnippetCmd(b Backend, shortID string) tea.Cmd {
34 +
	return func() tea.Msg {
35 +
		_, err := b.Delete(shortID)
36 +
		return snippetDeletedMsg{shortID: shortID, err: err}
37 +
	}
38 +
}
39 +
40 +
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41 +
	switch msg := msg.(type) {
42 +
43 +
	case tea.WindowSizeMsg:
44 +
		m.width, m.height = msg.Width, msg.Height
45 +
		m.ready = true
46 +
		m.resizePanes()
47 +
		return m, nil
48 +
49 +
	case snippetsLoadedMsg:
50 +
		m.loading = false
51 +
		if msg.err != nil {
52 +
			return m, m.setStatus("load: "+msg.err.Error(), false)
53 +
		}
54 +
		m.snippets = msg.snippets
55 +
		m.applyFilter(m.searchInput.Value())
56 +
		m.refreshPreview()
57 +
		return m, nil
58 +
59 +
	case snippetSavedMsg:
60 +
		if msg.err != nil {
61 +
			return m, m.setStatus("save: "+msg.err.Error(), false)
62 +
		}
63 +
		if msg.snippet != nil && m.highlighter != nil {
64 +
			m.highlighter.invalidate(msg.snippet.ShortID)
65 +
		}
66 +
		m.focus = FocusList
67 +
		m.nameInput.Reset()
68 +
		m.contentArea.Reset()
69 +
		m.editShortID = ""
70 +
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("saved", true))
71 +
72 +
	case snippetDeletedMsg:
73 +
		if msg.err != nil {
74 +
			return m, m.setStatus("delete: "+msg.err.Error(), false)
75 +
		}
76 +
		if m.highlighter != nil {
77 +
			m.highlighter.invalidate(msg.shortID)
78 +
		}
79 +
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("deleted", true))
80 +
81 +
	case editorFinishedMsg:
82 +
		if msg.err != nil {
83 +
			return m, m.setStatus("editor: "+msg.err.Error(), false)
84 +
		}
85 +
		if msg.shortID == "" {
86 +
			m.contentArea.SetValue(msg.content)
87 +
			return m, nil
88 +
		}
89 +
		var orig *Snippet
90 +
		for i := range m.snippets {
91 +
			if m.snippets[i].ShortID == msg.shortID {
92 +
				orig = &m.snippets[i]
93 +
				break
94 +
			}
95 +
		}
96 +
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.content, "\n") {
97 +
			return m, nil
98 +
		}
99 +
		return m, saveSnippetCmd(m.backend, msg.shortID, orig.Name, msg.content)
100 +
101 +
	case statusMsg:
102 +
		return m, m.setStatus(msg.text, msg.ok)
103 +
104 +
	case clearStatusMsg:
105 +
		m.status = ""
106 +
		return m, nil
107 +
108 +
	case tea.KeyMsg:
109 +
		return m.handleKey(msg)
110 +
	}
111 +
112 +
	return m, nil
113 +
}
114 +
115 +
func (m *Model) resizePanes() {
116 +
	if !m.ready {
117 +
		return
118 +
	}
119 +
	listW := m.width * 30 / 100
120 +
	if listW < 24 {
121 +
		listW = 24
122 +
	}
123 +
	contentW := m.width - listW - 2
124 +
	if contentW < 20 {
125 +
		contentW = 20
126 +
	}
127 +
	bodyH := m.height - 2
128 +
	if bodyH < 5 {
129 +
		bodyH = 5
130 +
	}
131 +
132 +
	m.contentVP.Width = contentW - 2
133 +
	m.contentVP.Height = bodyH - 2
134 +
135 +
	m.nameInput.Width = contentW - 4
136 +
	m.contentArea.SetWidth(contentW - 2)
137 +
	m.contentArea.SetHeight(bodyH - 5)
138 +
139 +
	m.searchInput.Width = listW - 4
140 +
141 +
	m.refreshPreview()
142 +
}
143 +
144 +
func (m *Model) refreshPreview() {
145 +
	s := m.current()
146 +
	if s == nil {
147 +
		m.contentVP.SetContent("")
148 +
		return
149 +
	}
150 +
	body := s.Content
151 +
	if m.highlighter != nil {
152 +
		body = m.highlighter.render(s.ShortID, s.Name, s.Content)
153 +
	}
154 +
	m.contentVP.SetContent(body)
155 +
}
156 +
157 +
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
158 +
	if msg.String() == "ctrl+c" {
159 +
		return m, tea.Quit
160 +
	}
161 +
162 +
	if m.confirmDelete {
163 +
		switch msg.String() {
164 +
		case "y", "Y":
165 +
			s := m.current()
166 +
			m.confirmDelete = false
167 +
			if s == nil {
168 +
				return m, nil
169 +
			}
170 +
			return m, deleteSnippetCmd(m.backend, s.ShortID)
171 +
		case "n", "N", "esc", "q":
172 +
			m.confirmDelete = false
173 +
			return m, nil
174 +
		}
175 +
		return m, nil
176 +
	}
177 +
178 +
	if m.showHelp {
179 +
		if key.Matches(msg, m.keys.Help) || msg.String() == "esc" || msg.String() == "q" {
180 +
			m.showHelp = false
181 +
		}
182 +
		return m, nil
183 +
	}
184 +
185 +
	switch m.focus {
186 +
	case FocusList:
187 +
		return m.keyList(msg)
188 +
	case FocusContent:
189 +
		return m.keyContent(msg)
190 +
	case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent:
191 +
		return m.keyForm(msg)
192 +
	case FocusSearch:
193 +
		return m.keySearch(msg)
194 +
	}
195 +
	return m, nil
196 +
}
197 +
198 +
func (m Model) keyList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
199 +
	list := m.visible()
200 +
	switch {
201 +
	case key.Matches(msg, m.keys.Quit):
202 +
		return m, tea.Quit
203 +
	case key.Matches(msg, m.keys.Down):
204 +
		if m.cursor < len(list)-1 {
205 +
			m.cursor++
206 +
			m.refreshPreview()
207 +
		}
208 +
	case key.Matches(msg, m.keys.Up):
209 +
		if m.cursor > 0 {
210 +
			m.cursor--
211 +
			m.refreshPreview()
212 +
		}
213 +
	case key.Matches(msg, m.keys.Open):
214 +
		if len(list) > 0 {
215 +
			m.focus = FocusContent
216 +
			m.contentVP.GotoTop()
217 +
		}
218 +
	case key.Matches(msg, m.keys.Create):
219 +
		m.focus = FocusCreateName
220 +
		m.editShortID = ""
221 +
		m.nameInput.SetValue("")
222 +
		m.contentArea.SetValue("")
223 +
		m.nameInput.Focus()
224 +
		m.contentArea.Blur()
225 +
	case key.Matches(msg, m.keys.Edit):
226 +
		s := m.current()
227 +
		if s != nil {
228 +
			m.focus = FocusEditName
229 +
			m.editShortID = s.ShortID
230 +
			m.nameInput.SetValue(s.Name)
231 +
			m.contentArea.SetValue(s.Content)
232 +
			m.nameInput.Focus()
233 +
			m.contentArea.Blur()
234 +
		}
235 +
	case key.Matches(msg, m.keys.ExtEdit):
236 +
		s := m.current()
237 +
		if s != nil {
238 +
			return m, openExternalEditor(s.ShortID, s.Name, s.Content)
239 +
		}
240 +
	case key.Matches(msg, m.keys.Delete):
241 +
		if m.current() != nil {
242 +
			m.confirmDelete = true
243 +
		}
244 +
	case key.Matches(msg, m.keys.Copy):
245 +
		s := m.current()
246 +
		if s != nil {
247 +
			if err := clipboard.WriteAll(s.Content); err != nil {
248 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
249 +
			}
250 +
			return m, m.setStatus("copied text", true)
251 +
		}
252 +
	case key.Matches(msg, m.keys.CopyLink):
253 +
		s := m.current()
254 +
		if s != nil && m.isRemote {
255 +
			link := m.shareURL(s.ShortID)
256 +
			if err := clipboard.WriteAll(link); err != nil {
257 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
258 +
			}
259 +
			return m, m.setStatus("copied link", true)
260 +
		}
261 +
		return m, m.setStatus("local mode: no link", false)
262 +
	case key.Matches(msg, m.keys.OpenBrowser):
263 +
		s := m.current()
264 +
		if s != nil && m.isRemote {
265 +
			link := m.shareURL(s.ShortID)
266 +
			if err := openURL(link); err != nil {
267 +
				return m, m.setStatus("open: "+err.Error(), false)
268 +
			}
269 +
			return m, m.setStatus("opened "+link, true)
270 +
		}
271 +
	case key.Matches(msg, m.keys.Search):
272 +
		m.focus = FocusSearch
273 +
		m.searchInput.Focus()
274 +
	case key.Matches(msg, m.keys.Refresh):
275 +
		m.loading = true
276 +
		return m, loadSnippetsCmd(m.backend)
277 +
	case key.Matches(msg, m.keys.Help):
278 +
		m.showHelp = true
279 +
	}
280 +
	return m, nil
281 +
}
282 +
283 +
func (m Model) keyContent(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
284 +
	switch {
285 +
	case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back):
286 +
		m.focus = FocusList
287 +
		return m, nil
288 +
	case key.Matches(msg, m.keys.Down):
289 +
		m.contentVP.ScrollDown(1)
290 +
	case key.Matches(msg, m.keys.Up):
291 +
		m.contentVP.ScrollUp(1)
292 +
	case key.Matches(msg, m.keys.Edit):
293 +
		s := m.current()
294 +
		if s != nil {
295 +
			m.focus = FocusEditName
296 +
			m.editShortID = s.ShortID
297 +
			m.nameInput.SetValue(s.Name)
298 +
			m.contentArea.SetValue(s.Content)
299 +
			m.nameInput.Focus()
300 +
		}
301 +
	case key.Matches(msg, m.keys.ExtEdit):
302 +
		s := m.current()
303 +
		if s != nil {
304 +
			return m, openExternalEditor(s.ShortID, s.Name, s.Content)
305 +
		}
306 +
	case key.Matches(msg, m.keys.Copy):
307 +
		s := m.current()
308 +
		if s != nil {
309 +
			clipboard.WriteAll(s.Content)
310 +
			return m, m.setStatus("copied text", true)
311 +
		}
312 +
	case key.Matches(msg, m.keys.CopyLink):
313 +
		s := m.current()
314 +
		if s != nil && m.isRemote {
315 +
			clipboard.WriteAll(m.shareURL(s.ShortID))
316 +
			return m, m.setStatus("copied link", true)
317 +
		}
318 +
	case key.Matches(msg, m.keys.OpenBrowser):
319 +
		s := m.current()
320 +
		if s != nil && m.isRemote {
321 +
			openURL(m.shareURL(s.ShortID))
322 +
		}
323 +
	case key.Matches(msg, m.keys.Help):
324 +
		m.showHelp = true
325 +
	}
326 +
	var cmd tea.Cmd
327 +
	m.contentVP, cmd = m.contentVP.Update(msg)
328 +
	return m, cmd
329 +
}
330 +
331 +
func (m Model) keyForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
332 +
	switch {
333 +
	case key.Matches(msg, m.keys.Cancel):
334 +
		m.focus = FocusList
335 +
		m.nameInput.Blur()
336 +
		m.contentArea.Blur()
337 +
		return m, nil
338 +
	case key.Matches(msg, m.keys.Save):
339 +
		name := strings.TrimSpace(m.nameInput.Value())
340 +
		if name == "" {
341 +
			return m, m.setStatus("name required", false)
342 +
		}
343 +
		content := m.contentArea.Value()
344 +
		if strings.TrimSpace(content) == "" {
345 +
			return m, m.setStatus("content required", false)
346 +
		}
347 +
		return m, saveSnippetCmd(m.backend, m.editShortID, name, content)
348 +
	case key.Matches(msg, m.keys.SwitchField):
349 +
		switch m.focus {
350 +
		case FocusCreateName:
351 +
			m.focus = FocusCreateContent
352 +
		case FocusCreateContent:
353 +
			m.focus = FocusCreateName
354 +
		case FocusEditName:
355 +
			m.focus = FocusEditContent
356 +
		case FocusEditContent:
357 +
			m.focus = FocusEditName
358 +
		}
359 +
		m.applyFormFocus()
360 +
		return m, nil
361 +
	}
362 +
363 +
	var cmd tea.Cmd
364 +
	switch m.focus {
365 +
	case FocusCreateName, FocusEditName:
366 +
		m.nameInput, cmd = m.nameInput.Update(msg)
367 +
	case FocusCreateContent, FocusEditContent:
368 +
		m.contentArea, cmd = m.contentArea.Update(msg)
369 +
	}
370 +
	return m, cmd
371 +
}
372 +
373 +
func (m *Model) applyFormFocus() {
374 +
	switch m.focus {
375 +
	case FocusCreateName, FocusEditName:
376 +
		m.nameInput.Focus()
377 +
		m.contentArea.Blur()
378 +
	case FocusCreateContent, FocusEditContent:
379 +
		m.contentArea.Focus()
380 +
		m.nameInput.Blur()
381 +
	}
382 +
}
383 +
384 +
func (m Model) keySearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
385 +
	switch msg.String() {
386 +
	case "esc":
387 +
		m.searchInput.SetValue("")
388 +
		m.searchInput.Blur()
389 +
		m.focus = FocusList
390 +
		m.applyFilter("")
391 +
		m.refreshPreview()
392 +
		return m, nil
393 +
	case "enter":
394 +
		m.searchInput.Blur()
395 +
		m.focus = FocusList
396 +
		return m, nil
397 +
	}
398 +
	var cmd tea.Cmd
399 +
	m.searchInput, cmd = m.searchInput.Update(msg)
400 +
	m.applyFilter(m.searchInput.Value())
401 +
	m.refreshPreview()
402 +
	return m, cmd
403 +
}
apps/sipp-go/tui/view.go (added) +192 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"strings"
6 +
7 +
	"github.com/charmbracelet/lipgloss"
8 +
)
9 +
10 +
var (
11 +
	borderStyle = lipgloss.NewStyle().
12 +
			Border(lipgloss.RoundedBorder()).
13 +
			BorderForeground(lipgloss.Color("240"))
14 +
	borderActive = lipgloss.NewStyle().
15 +
			Border(lipgloss.RoundedBorder()).
16 +
			BorderForeground(lipgloss.Color("214"))
17 +
	titleStyle = lipgloss.NewStyle().
18 +
			Bold(true).
19 +
			Foreground(lipgloss.Color("214")).
20 +
			Padding(0, 1)
21 +
	itemStyle    = lipgloss.NewStyle().Padding(0, 1)
22 +
	itemSelected = lipgloss.NewStyle().
23 +
			Padding(0, 1).
24 +
			Bold(true).
25 +
			Foreground(lipgloss.Color("214"))
26 +
	dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
27 +
	statusOK = lipgloss.NewStyle().
28 +
			Foreground(lipgloss.Color("82")).
29 +
			Bold(true)
30 +
	statusErr = lipgloss.NewStyle().
31 +
			Foreground(lipgloss.Color("196")).
32 +
			Bold(true)
33 +
	hintStyle = lipgloss.NewStyle().
34 +
			Foreground(lipgloss.Color("244"))
35 +
	modalStyle = lipgloss.NewStyle().
36 +
			Border(lipgloss.RoundedBorder()).
37 +
			BorderForeground(lipgloss.Color("214")).
38 +
			Padding(1, 2).
39 +
			Background(lipgloss.Color("236"))
40 +
)
41 +
42 +
func (m Model) View() string {
43 +
	if !m.ready {
44 +
		return "loading..."
45 +
	}
46 +
47 +
	listW := m.width * 30 / 100
48 +
	if listW < 24 {
49 +
		listW = 24
50 +
	}
51 +
	contentW := m.width - listW - 2
52 +
	bodyH := m.height - 2
53 +
54 +
	left := m.renderList(listW, bodyH)
55 +
	right := m.renderRight(contentW, bodyH)
56 +
57 +
	body := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
58 +
	footer := m.renderFooter()
59 +
60 +
	view := lipgloss.JoinVertical(lipgloss.Left, body, footer)
61 +
62 +
	if m.showHelp {
63 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
64 +
			modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())),
65 +
			lipgloss.WithWhitespaceChars(" "))
66 +
	}
67 +
	if m.confirmDelete {
68 +
		s := m.current()
69 +
		name := ""
70 +
		if s != nil {
71 +
			name = s.Name
72 +
		}
73 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
74 +
			modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", name)),
75 +
			lipgloss.WithWhitespaceChars(" "))
76 +
	}
77 +
	if m.status != "" {
78 +
		st := statusOK
79 +
		if !m.statusOK {
80 +
			st = statusErr
81 +
		}
82 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Bottom,
83 +
			modalStyle.Render(st.Render(m.status)),
84 +
			lipgloss.WithWhitespaceChars(" "))
85 +
	}
86 +
	return view
87 +
}
88 +
89 +
func (m Model) renderList(w, h int) string {
90 +
	style := borderStyle
91 +
	if m.focus == FocusList || m.focus == FocusSearch {
92 +
		style = borderActive
93 +
	}
94 +
95 +
	list := m.visible()
96 +
	rows := make([]string, 0, len(list)+2)
97 +
	rows = append(rows, titleStyle.Render("snippets"))
98 +
	if len(list) == 0 {
99 +
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
100 +
	}
101 +
	for i, s := range list {
102 +
		label := s.Name
103 +
		if label == "" {
104 +
			label = s.ShortID
105 +
		}
106 +
		line := truncate(label, w-6)
107 +
		id := dimStyle.Render(" " + s.ShortID)
108 +
		if i == m.cursor {
109 +
			rows = append(rows, itemSelected.Render("▶ "+line)+id)
110 +
		} else {
111 +
			rows = append(rows, itemStyle.Render("  "+line)+id)
112 +
		}
113 +
	}
114 +
115 +
	if m.focus == FocusSearch || m.searchInput.Value() != "" {
116 +
		rows = append(rows, "", hintStyle.Render(m.searchInput.View()))
117 +
	}
118 +
119 +
	content := strings.Join(rows, "\n")
120 +
	return style.Width(w).Height(h).Render(content)
121 +
}
122 +
123 +
func (m Model) renderRight(w, h int) string {
124 +
	switch m.focus {
125 +
	case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent:
126 +
		return m.renderForm(w, h)
127 +
	}
128 +
	return m.renderContent(w, h)
129 +
}
130 +
131 +
func (m Model) renderContent(w, h int) string {
132 +
	style := borderStyle
133 +
	if m.focus == FocusContent {
134 +
		style = borderActive
135 +
	}
136 +
	header := "preview"
137 +
	s := m.current()
138 +
	if s != nil {
139 +
		header = s.Name
140 +
		if header == "" {
141 +
			header = s.ShortID
142 +
		}
143 +
	}
144 +
	body := m.contentVP.View()
145 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
146 +
	return style.Width(w).Height(h).Render(inner)
147 +
}
148 +
149 +
func (m Model) renderForm(w, h int) string {
150 +
	header := "new snippet"
151 +
	if m.editShortID != "" {
152 +
		header = "edit"
153 +
	}
154 +
	name := m.nameInput.View()
155 +
	if m.focus == FocusCreateName || m.focus == FocusEditName {
156 +
		name = borderActive.Render(name)
157 +
	} else {
158 +
		name = borderStyle.Render(name)
159 +
	}
160 +
161 +
	body := m.contentArea.View()
162 +
	if m.focus == FocusCreateContent || m.focus == FocusEditContent {
163 +
		body = borderActive.Render(body)
164 +
	} else {
165 +
		body = borderStyle.Render(body)
166 +
	}
167 +
168 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), name, body)
169 +
	return borderStyle.Width(w).Height(h).Render(inner)
170 +
}
171 +
172 +
func (m Model) renderFooter() string {
173 +
	mode := "local"
174 +
	if m.isRemote {
175 +
		mode = "remote " + m.backend.RemoteURL()
176 +
	}
177 +
	help := m.help.ShortHelpView(m.keys.ShortHelp())
178 +
	return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help))
179 +
}
180 +
181 +
func truncate(s string, n int) string {
182 +
	if n < 1 {
183 +
		return ""
184 +
	}
185 +
	if len(s) <= n {
186 +
		return s
187 +
	}
188 +
	if n <= 1 {
189 +
		return "…"
190 +
	}
191 +
	return s[:n-1] + "…"
192 +
}
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/darkmatter.css (added) +648 −0
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 +
}
crates-go/darkmatter/assets/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

crates-go/darkmatter/assets/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

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/sqlite/go.mod (added) +18 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/sqlite
2 +
3 +
go 1.24
4 +
5 +
require modernc.org/sqlite v1.37.1
6 +
7 +
require (
8 +
	github.com/dustin/go-humanize v1.0.1 // indirect
9 +
	github.com/google/uuid v1.6.0 // indirect
10 +
	github.com/mattn/go-isatty v0.0.20 // indirect
11 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
12 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
13 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
14 +
	golang.org/x/sys v0.33.0 // indirect
15 +
	modernc.org/libc v1.65.7 // indirect
16 +
	modernc.org/mathutil v1.7.1 // indirect
17 +
	modernc.org/memory v1.11.0 // indirect
18 +
)
crates-go/sqlite/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=
crates-go/sqlite/sqlite.go (added) +31 −0
1 +
// Package sqlite provides a shared SQLite bootstrap for andromeda Go apps.
2 +
package sqlite
3 +
4 +
import (
5 +
	"database/sql"
6 +
7 +
	_ "modernc.org/sqlite"
8 +
)
9 +
10 +
// Open opens a SQLite database at path with the connection settings and
11 +
// PRAGMAs used across andromeda Go apps. If schema is non-empty it is
12 +
// executed once after opening (typically CREATE TABLE IF NOT EXISTS ...).
13 +
func Open(path string, schema string) (*sql.DB, error) {
14 +
	db, err := sql.Open("sqlite", path)
15 +
	if err != nil {
16 +
		return nil, err
17 +
	}
18 +
	db.SetMaxOpenConns(1)
19 +
	db.SetMaxIdleConns(1)
20 +
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
21 +
		db.Close()
22 +
		return nil, err
23 +
	}
24 +
	if schema != "" {
25 +
		if _, err := db.Exec(schema); err != nil {
26 +
			db.Close()
27 +
			return nil, err
28 +
		}
29 +
	}
30 +
	return db, nil
31 +
}
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) +89 −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 +
	"strconv"
14 +
	"strings"
15 +
)
16 +
17 +
// EmbeddedHandler serves files from an embed.FS under the given URL prefix.
18 +
// Files are looked up relative to the same prefix inside the embedded FS.
19 +
func EmbeddedHandler(fs embed.FS, prefix string) http.HandlerFunc {
20 +
	return func(w http.ResponseWriter, r *http.Request) {
21 +
		name := strings.TrimPrefix(r.URL.Path, "/"+prefix+"/")
22 +
		path := filepath.ToSlash(filepath.Join(prefix, name))
23 +
		data, err := fs.ReadFile(path)
24 +
		if err != nil {
25 +
			http.NotFound(w, r)
26 +
			return
27 +
		}
28 +
		if ct := mime.TypeByExtension(filepath.Ext(path)); ct != "" {
29 +
			w.Header().Set("Content-Type", ct)
30 +
		}
31 +
		_, _ = w.Write(data)
32 +
	}
33 +
}
34 +
35 +
// Render executes a named template into w with status 200. Errors are logged
36 +
// and surfaced as HTTP 500.
37 +
func Render(t *template.Template, w http.ResponseWriter, name string, data any, log *slog.Logger) {
38 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
39 +
	if err := t.ExecuteTemplate(w, name, data); err != nil {
40 +
		if log != nil {
41 +
			log.Error("template render failed", "name", name, "err", err)
42 +
		}
43 +
		http.Error(w, "template error", http.StatusInternalServerError)
44 +
	}
45 +
}
46 +
47 +
// WriteJSON writes data as JSON with the given status code.
48 +
func WriteJSON(w http.ResponseWriter, status int, data any) {
49 +
	w.Header().Set("Content-Type", "application/json")
50 +
	w.WriteHeader(status)
51 +
	_ = json.NewEncoder(w).Encode(data)
52 +
}
53 +
54 +
// DecodeJSON decodes r.Body into dst. On failure it writes a 400 JSON error
55 +
// and returns false.
56 +
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
57 +
	defer r.Body.Close()
58 +
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
59 +
		WriteError(w, http.StatusBadRequest, "invalid JSON")
60 +
		return false
61 +
	}
62 +
	return true
63 +
}
64 +
65 +
// WriteError writes a JSON error response of the form {"error": msg} with the
66 +
// given status code.
67 +
func WriteError(w http.ResponseWriter, status int, msg string) {
68 +
	WriteJSON(w, status, map[string]any{"error": msg})
69 +
}
70 +
71 +
// PathInt64 parses a positive int64 path value from r. Returns (0, false) if
72 +
// missing, unparseable, or non-positive.
73 +
func PathInt64(r *http.Request, name string) (int64, bool) {
74 +
	id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
75 +
	if err != nil || id <= 0 {
76 +
		return 0, false
77 +
	}
78 +
	return id, true
79 +
}
80 +
81 +
// RedirectWithError issues a 303 redirect to target with ?error=msg appended.
82 +
func RedirectWithError(w http.ResponseWriter, r *http.Request, target, msg string) {
83 +
	http.Redirect(w, r, target+"?error="+url.QueryEscape(msg), http.StatusSeeOther)
84 +
}
85 +
86 +
// RedirectWithSuccess issues a 303 redirect to target with ?success=msg appended.
87 +
func RedirectWithSuccess(w http.ResponseWriter, r *http.Request, target, msg string) {
88 +
	http.Redirect(w, r, target+"?success="+url.QueryEscape(msg), http.StatusSeeOther)
89 +
}
gaps-plan.md (added) +179 −0
1 +
# Plan: bring Go ports to parity with Rust originals
2 +
3 +
## Context
4 +
5 +
Repo `/home/stevedylandev/Developer/andromeda` has paired Rust + Go versions of 10 apps. Audit (Phase 1) found gaps in 4 Go apps. User wants full parity. Deep-dive (Phase 2) corrected the initial gap list — some "missing" items in jotts-go are already implemented, and the posts EXIF strip referenced earlier does **not** exist in the Rust source either.
6 +
7 +
This plan covers only the real, confirmed gaps.
8 +
9 +
---
10 +
11 +
## Apps at parity (no work)
12 +
13 +
bookmarks, cellar, easel, feeds, library, og.
14 +
15 +
---
16 +
17 +
## Gap 1 — `posts-go`: R2/S3 storage backend
18 +
19 +
**Rust source to mirror:**
20 +
- `apps/posts/src/storage.rs` — `R2Config { bucket, creds, public_url, http }`, methods `from_env`, `put_object`, `delete_object`, `public_url_for`. Uses `rusty_s3 = "0.9"` + reqwest.
21 +
- `apps/posts/src/server/mod.rs:552` — init at startup, store `Option<R2Config>` in `AppState`.
22 +
- `apps/posts/src/server/handlers/admin.rs:439-522` — `admin_upload_file` routes via `if let Some(r2) = &state.r2`, sets `storage_backend` to `"r2"` or `"local"`, rolls back on failure.
23 +
- `apps/posts/src/server/handlers/public.rs:165-199` — `serve_uploaded_file` redirects to `r2.public_url_for(filename)` when backend is r2.
24 +
25 +
**EXIF strip is NOT in Rust** — drop from scope.
26 +
27 +
**Go changes (apps/posts-go/):**
28 +
- New package `storage/` with interface:
29 +
  ```go
30 +
  type Backend interface {
31 +
      Put(ctx context.Context, key, contentType string, data []byte) error
32 +
      Delete(ctx context.Context, key string) error
33 +
      PublicURL(key string) string
34 +
      Name() string // "local" | "r2"
35 +
  }
36 +
  ```
37 +
- `storage/local.go` — wrap existing FS funcs from `storage.go`.
38 +
- `storage/r2.go` — use `github.com/aws/aws-sdk-go-v2/service/s3` with custom endpoint resolver for R2 (`https://<account>.r2.cloudflarestorage.com`). Or `github.com/minio/minio-go/v7` for less ceremony.
39 +
- `app.go` — add `Storage storage.Backend` field.
40 +
- `main.go` — `if os.Getenv("R2_BUCKET") != "" { storage.NewR2(...) } else { storage.NewLocal(uploadsDir) }`.
41 +
- `handlers_admin.go` (~line 301 `adminUploadFile`) — call `a.Storage.Put(...)`, capture `a.Storage.Name()` for DB insert, delete on rollback.
42 +
- `handlers_public.go` (~line 156 `serveUploadedFile`) — if `f.StorageBackend == "r2"`, `http.Redirect(... StatusTemporaryRedirect)`.
43 +
- `db.go` (~line 395 `createFile`) — pass backend string; column already exists.
44 +
- `.env.example` — add `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PUBLIC_URL`.
45 +
46 +
---
47 +
48 +
## Gap 2 — `shrink-go`: EXIF preserve + GPS strip
49 +
50 +
**Rust source to mirror:** `apps/shrink/src/server.rs:103-207` (`strip_gps_from_exif`). Crates: `img-parts = "0.3"` + `image = "0.25"`. Algorithm:
51 +
1. `Jpeg::from_bytes(orig)` → `j.exif().to_vec()` (raw APP1 payload).
52 +
2. Re-encode resized JPEG (loses EXIF).
53 +
3. Parse raw EXIF: TIFF header (II/MM), read IFD0 offset, walk entries (12 bytes each) for tag `0x8825` (GPS IFD pointer), zero the GPS IFD's entry count (2 bytes at the pointed offset).
54 +
4. `out_jpeg.set_exif(Some(exif.into()))`, write final bytes.
55 +
56 +
**Go changes (apps/shrink-go/):**
57 +
- Add deps: `github.com/dsoprea/go-exif/v3` and `github.com/dsoprea/go-jpeg-image-structure/v2`.
58 +
- New `exif.go`:
59 +
  - `extractExif(orig []byte) []byte` — use go-jpeg-image-structure segment list, return APP1 bytes (skip first 6 `Exif\0\0` prefix).
60 +
  - `stripGPS(exif []byte) []byte` — mirror Rust byte-level walk (don't use go-exif builder; cheaper to keep parity).
61 +
  - `injectExif(jpeg, exif []byte) []byte` — splice modified APP1 after SOI marker.
62 +
- `image.go` / `handlers.go` — wrap compress flow: extract before resize, strip GPS, inject after re-encode.
63 +
64 +
---
65 +
66 +
## Gap 3 — `sipp-go`: content wrap toggle (TUI theme is out of scope)
67 +
68 +
**Already done in Go** (verified via `apps/sipp-go/tui/`): syntect→chroma highlight, clipboard auto-copy on select (`update.go:244-261`), line numbers (`ShowLineNumbers = true`), vim keybindings (`keys.go`).
69 +
70 +
**Remaining:** Ctrl+W content wrap toggle (Rust `wrap_content: bool`).
71 +
72 +
**Go changes (apps/sipp-go/tui/):**
73 +
- `model.go` — add `wrapContent bool` field.
74 +
- `keys.go` — add `WrapToggle` binding (`ctrl+w`).
75 +
- `update.go` — when focus is content view/edit and `WrapToggle` matches, flip flag, reset scroll, emit status message.
76 +
- `view.go` — when rendering content viewport/textarea, branch on `wrapContent`.
77 +
78 +
**Custom .tmTheme loading:** Chroma has no native TextMate XML loader. Defer — README already documents the trade-off. Re-open as a follow-up if user wants it; will require either a tmTheme→chroma converter or hand-porting the two themes to chroma `chroma.MustNewStyle`.
79 +
80 +
---
81 +
82 +
## Gap 4 — `jotts-go`: complete the TUI editor
83 +
84 +
**Already done in Go** (verified):
85 +
- `cmd_auth.go` — interactive auth + `~/.config/jotts/config.toml` (BurntSushi/toml).
86 +
- `cmd_upload.go` — file → note + clipboard.
87 +
- `cmd_server.go:29` — startup `sessions.PruneExpired()` (in `crates-go/auth/auth.go`).
88 +
89 +
**Remaining:** interactive TUI editor. Mirror `apps/jotts/src/tui/{app,events,render}.rs` + `apps/jotts/src/tui.rs`.
90 +
91 +
**Go changes (apps/jotts-go/tui/):**
92 +
- `model.go` — `Focus` enum: List, Content, CreateTitle, CreateContent, EditTitle, EditContent, Search.
93 +
- `keys.go` — vim bindings: `hjkl`, `y` (copy content), `Y` (copy share link), `d` (delete with confirm), `c` (new), `e` (edit), `E` (external editor), `o` (open in browser), `/` (search), `?` (help), `q`/`esc` (quit/back).
94 +
- `update.go` — mode FSM driving Focus transitions; copy triggers via `atotto/clipboard.WriteAll`; status-line messages.
95 +
- `editor.go` (new) — external editor: read `$EDITOR`, write content to `os.CreateTemp`, `exec.Command(editor, path).Run()` with stdio attached to current TTY, re-read file on exit.
96 +
- `browser.go` (new) — open `<remote_url>/notes/<short_id>` via `github.com/pkg/browser` (cross-platform).
97 +
- `view.go` — two-pane layout (lipgloss `JoinHorizontal`, 30/70), borders/title styles, help line at bottom.
98 +
- Markdown render: keep existing `glamour` (acceptable substitute for syntect — agent confirmed parity in behavior).
99 +
- `backend.go` — already abstracts local/remote; reuse.
100 +
101 +
**Deps to add:** `github.com/pkg/browser` (everything else already in go.mod).
102 +
103 +
---
104 +
105 +
## Critical files (modified or created)
106 +
107 +
| App | Path | Action |
108 +
|-----|------|--------|
109 +
| posts-go | `storage/{interface,local,r2}.go` | new |
110 +
| posts-go | `app.go`, `main.go`, `handlers_admin.go`, `handlers_public.go`, `db.go`, `.env.example` | edit |
111 +
| shrink-go | `exif.go` | new |
112 +
| shrink-go | `image.go`, `handlers.go`, `go.mod` | edit |
113 +
| sipp-go | `tui/{model,keys,update,view}.go` | edit |
114 +
| jotts-go | `tui/{editor,browser}.go` | new |
115 +
| jotts-go | `tui/{model,keys,update,view}.go`, `go.mod` | edit |
116 +
117 +
---
118 +
119 +
## Reused existing utilities
120 +
121 +
- `crates-go/auth/auth.go` — sessions, bearer/session middleware (no changes).
122 +
- `crates-go/web` — embedded handler, render helpers (no changes).
123 +
- `crates-go/sqlite`, `crates-go/config`, `crates-go/darkmatter` — reused as-is.
124 +
- posts-go local FS funcs already in `storage.go` — wrap into `LocalStorage`.
125 +
- sipp-go clipboard + chroma flow already wired — only add wrap toggle.
126 +
- jotts-go `tui/backend.go` (local + remote impls) — reused.
127 +
128 +
---
129 +
130 +
## Execution order (suggested)
131 +
132 +
1. **shrink-go EXIF** — smallest, self-contained, no schema changes.
133 +
2. **sipp-go wrap toggle** — ~20 LoC, fast win.
134 +
3. **posts-go R2** — biggest data-path change; needs R2 creds for end-to-end test.
135 +
4. **jotts-go TUI** — largest, mostly UI iteration; do last so other parity work isn't blocked.
136 +
137 +
---
138 +
139 +
## Verification
140 +
141 +
Per app:
142 +
143 +
**shrink-go**
144 +
```bash
145 +
cd apps/shrink-go && go build ./... && go run .
146 +
# upload a JPEG with GPS via the form, download result, run:
147 +
exiftool result.jpg | rg -i 'gps|orientation|make|model'
148 +
# expect: GPS fields absent, other EXIF present
149 +
```
150 +
151 +
**sipp-go**
152 +
```bash
153 +
cd apps/sipp-go && go run ./cmd/server
154 +
# open snippet, press Ctrl+W, confirm wrap behavior toggles, scroll resets
155 +
```
156 +
157 +
**posts-go**
158 +
```bash
159 +
cd apps/posts-go && go build ./... && go test ./...
160 +
# unset R2 vars: upload via admin → file lands in uploads/, GET /uploads/<f> returns 200
161 +
# set R2 vars (test bucket): upload → check bucket contains object, GET returns 307 to R2 public URL
162 +
sqlite3 posts.sqlite "select storage_backend from files order by id desc limit 5;"
163 +
```
164 +
165 +
**jotts-go**
166 +
```bash
167 +
cd apps/jotts-go && go run . server &
168 +
go run . tui --remote http://localhost:3000 --api-key $KEY
169 +
# walk: create note, edit, y copies content, Y copies link, E opens $EDITOR, o opens browser, d deletes
170 +
```
171 +
172 +
Cross-cutting: `go vet ./...` and `go build ./...` from repo root after each app's changes.
173 +
174 +
---
175 +
176 +
## Out of scope (recorded for follow-up)
177 +
178 +
- sipp-go custom `.tmTheme` loading — needs chroma converter or hand-port; defer per README's existing call-out.
179 +
- posts-go EXIF strip on upload — not in Rust either; only do if user asks separately.