| 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 |
| 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. |
| 2 | 2 | ||
| 3 | 3 |  |
|
| 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 | [](https://railway.com/deploy/tepdeI?referralCode=JGcIp6) | |
|
| 21 | 24 | | [**Easel**](apps/easel) | Daily public-domain painting from the Art Institute of Chicago | [](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. |
|
| 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 |
| 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"] |
| 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`. |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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 | + | } |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* feeds — app-specific styles. |
|
| 2 | + | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | + | */ |
|
| 4 | + | ||
| 5 | + | /* The logo wraps an h1 in feeds markup. */ |
|
| 6 | + | ||
| 7 | + | .logo h1 { |
|
| 8 | + | font-size: 28px; |
|
| 9 | + | font-weight: 700; |
|
| 10 | + | text-transform: uppercase; |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | .about { |
|
| 14 | + | display: flex; |
|
| 15 | + | flex-direction: column; |
|
| 16 | + | gap: 0.5rem; |
|
| 17 | + | font-size: 14px; |
|
| 18 | + | line-height: 1.25rem; |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /* Feeds list */ |
|
| 22 | + | ||
| 23 | + | .feeds-list { |
|
| 24 | + | width: 100%; |
|
| 25 | + | display: flex; |
|
| 26 | + | flex-direction: column; |
|
| 27 | + | gap: 1.5rem; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | .feed-item { |
|
| 31 | + | display: flex; |
|
| 32 | + | flex-direction: column; |
|
| 33 | + | gap: 0.5rem; |
|
| 34 | + | padding: 1rem 0; |
|
| 35 | + | border-bottom: 1px solid #333; |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | .feed-item:last-child { |
|
| 39 | + | border-bottom: none; |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | .feed-meta { |
|
| 43 | + | display: flex; |
|
| 44 | + | justify-content: space-between; |
|
| 45 | + | align-items: center; |
|
| 46 | + | font-size: 12px; |
|
| 47 | + | opacity: 0.5; |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | .feed-source { |
|
| 51 | + | font-weight: 700; |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | .feed-title { |
|
| 55 | + | font-size: 16px; |
|
| 56 | + | font-weight: 400; |
|
| 57 | + | line-height: 1.4; |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | .feed-title a { |
|
| 61 | + | text-decoration: none; |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | .feed-author { |
|
| 65 | + | font-size: 12px; |
|
| 66 | + | opacity: 0.5; |
|
| 67 | + | font-style: italic; |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | #feed-urls { |
|
| 71 | + | font-size: 12px; |
|
| 72 | + | opacity: 0.5; |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | .no-feeds, |
|
| 76 | + | #loading { |
|
| 77 | + | text-align: center; |
|
| 78 | + | opacity: 0.5; |
|
| 79 | + | padding: 2rem; |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | #error { |
|
| 83 | + | text-align: center; |
|
| 84 | + | padding: 2rem; |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /* Admin forms */ |
|
| 88 | + | ||
| 89 | + | .admin-form { |
|
| 90 | + | display: flex; |
|
| 91 | + | flex-direction: column; |
|
| 92 | + | gap: 0.75rem; |
|
| 93 | + | width: 100%; |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | .admin-form h3 { |
|
| 97 | + | font-size: 14px; |
|
| 98 | + | font-weight: 400; |
|
| 99 | + | opacity: 0.5; |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | .admin-notice, |
|
| 103 | + | .hint { |
|
| 104 | + | font-size: 12px; |
|
| 105 | + | opacity: 0.5; |
|
| 106 | + | line-height: 1.4; |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /* Discover panel */ |
|
| 110 | + | ||
| 111 | + | .discover-row { |
|
| 112 | + | display: flex; |
|
| 113 | + | gap: 0.5rem; |
|
| 114 | + | width: 100%; |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | .discover-row input { |
|
| 118 | + | flex: 1; |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | .discover-status { |
|
| 122 | + | font-size: 12px; |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | .discover-results { |
|
| 126 | + | display: flex; |
|
| 127 | + | flex-direction: column; |
|
| 128 | + | gap: 0.25rem; |
|
| 129 | + | width: 100%; |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | .discover-result-item { |
|
| 133 | + | background: #121113; |
|
| 134 | + | color: #ffffff; |
|
| 135 | + | border: 1px solid #333; |
|
| 136 | + | padding: 8px 10px; |
|
| 137 | + | font-size: 12px; |
|
| 138 | + | text-align: left; |
|
| 139 | + | cursor: pointer; |
|
| 140 | + | width: 100%; |
|
| 141 | + | white-space: nowrap; |
|
| 142 | + | overflow: hidden; |
|
| 143 | + | text-overflow: ellipsis; |
|
| 144 | + | opacity: 0.7; |
|
| 145 | + | border-radius: 0; |
|
| 146 | + | -webkit-appearance: none; |
|
| 147 | + | appearance: none; |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | .discover-result-item:hover { |
|
| 151 | + | border-color: #555; |
|
| 152 | + | opacity: 1; |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | .discover-result-item.active { |
|
| 156 | + | border-color: #ffffff; |
|
| 157 | + | opacity: 1; |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /* Admin subs */ |
|
| 161 | + | ||
| 162 | + | .admin-subs { |
|
| 163 | + | width: 100%; |
|
| 164 | + | display: flex; |
|
| 165 | + | flex-direction: column; |
|
| 166 | + | gap: 1rem; |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | .admin-subs h3 { |
|
| 170 | + | font-size: 14px; |
|
| 171 | + | opacity: 0.5; |
|
| 172 | + | font-weight: 400; |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | .feed-item form.inline { |
|
| 176 | + | display: flex; |
|
| 177 | + | gap: 0.5rem; |
|
| 178 | + | align-items: center; |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | .feed-item form.inline input { |
|
| 182 | + | flex: 1; |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | /* Generic .danger on buttons (used in admin) */ |
|
| 186 | + | ||
| 187 | + | button.danger, |
|
| 188 | + | .btn.danger { |
|
| 189 | + | opacity: 0.5; |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | button.danger:hover, |
|
| 193 | + | .btn.danger:hover { |
|
| 194 | + | opacity: 0.3; |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | /* Category list (admin) */ |
|
| 198 | + | ||
| 199 | + | .category-list { |
|
| 200 | + | list-style: none; |
|
| 201 | + | margin-left: 0; |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | .category-list li { |
|
| 205 | + | display: flex; |
|
| 206 | + | justify-content: space-between; |
|
| 207 | + | align-items: center; |
|
| 208 | + | padding: 0.25rem 0; |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | @media (max-width: 480px) { |
|
| 212 | + | .feed-meta { |
|
| 213 | + | flex-direction: column; |
|
| 214 | + | align-items: flex-start; |
|
| 215 | + | gap: 0.25rem; |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | .feed-title { |
|
| 219 | + | font-size: 14px; |
|
| 220 | + | } |
|
| 221 | + | } |
| 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> |
| 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> |
| 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> |
| 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 |
| 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"] |
| 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. |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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) |
| 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 | + | ) |
| 1 | + | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|
| 2 | + | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|
| 3 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
|
| 4 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
|
| 5 | + | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
|
| 6 | + | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|
| 7 | + | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|
| 8 | + | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|
| 9 | + | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|
| 10 | + | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
|
| 11 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
|
| 12 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 13 | + | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
|
| 14 | + | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
|
| 15 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= |
|
| 16 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= |
|
| 17 | + | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= |
|
| 18 | + | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= |
|
| 19 | + | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= |
|
| 20 | + | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= |
|
| 21 | + | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 22 | + | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
|
| 23 | + | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
|
| 24 | + | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= |
|
| 25 | + | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= |
|
| 26 | + | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= |
|
| 27 | + | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= |
|
| 28 | + | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= |
|
| 29 | + | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= |
|
| 30 | + | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= |
|
| 31 | + | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= |
|
| 32 | + | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
|
| 33 | + | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
|
| 34 | + | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= |
|
| 35 | + | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= |
|
| 36 | + | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
|
| 37 | + | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
|
| 38 | + | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
|
| 39 | + | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
|
| 40 | + | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= |
|
| 41 | + | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
|
| 42 | + | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
|
| 43 | + | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
|
| 44 | + | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= |
|
| 45 | + | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= |
|
| 46 | + | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
|
| 47 | + | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
|
| 48 | + | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
|
| 49 | + | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "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 | + | } |
| 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 | + | } |
| 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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") |
|
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* 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 | + | } |
| 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 | + | } |
| 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}} · {{.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}} |
| 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}} |
| 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}} · {{.Wine.Grape}}{{end}}</span> |
|
| 22 | + | </div> |
|
| 23 | + | </a> |
|
| 24 | + | {{end}} |
|
| 25 | + | </div> |
|
| 26 | + | {{end}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} · {{.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}} |
| 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}} |
| 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 |
| 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"] |
| 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`). |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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 | + | "&", "&", |
|
| 19 | + | "<", "<", |
|
| 20 | + | ">", ">", |
|
| 21 | + | `"`, """, |
|
| 22 | + | "'", "'", |
|
| 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 | + | } |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | .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 | + | } |
| 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}} |
| 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}} |
| 1 | + | {{define "day.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}Easel — {{.Date}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | {{template "artwork" .Artwork}} |
|
| 5 | + | {{end}} |
| 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}} |
| 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}} |
| 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 |
| 1 | + | feeds-go.sqlite |
|
| 2 | + | .env |
| 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"] |
| 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 |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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 | + | } |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* feeds — app-specific styles. |
|
| 2 | + | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | + | */ |
|
| 4 | + | ||
| 5 | + | /* The logo wraps an h1 in feeds markup. */ |
|
| 6 | + | ||
| 7 | + | .logo h1 { |
|
| 8 | + | font-size: 28px; |
|
| 9 | + | font-weight: 700; |
|
| 10 | + | text-transform: uppercase; |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | .about { |
|
| 14 | + | display: flex; |
|
| 15 | + | flex-direction: column; |
|
| 16 | + | gap: 0.5rem; |
|
| 17 | + | font-size: 14px; |
|
| 18 | + | line-height: 1.25rem; |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /* Feeds list */ |
|
| 22 | + | ||
| 23 | + | .feeds-list { |
|
| 24 | + | width: 100%; |
|
| 25 | + | display: flex; |
|
| 26 | + | flex-direction: column; |
|
| 27 | + | gap: 1.5rem; |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | .feed-item { |
|
| 31 | + | display: flex; |
|
| 32 | + | flex-direction: column; |
|
| 33 | + | gap: 0.5rem; |
|
| 34 | + | padding: 1rem 0; |
|
| 35 | + | border-bottom: 1px solid #333; |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | .feed-item:last-child { |
|
| 39 | + | border-bottom: none; |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | .feed-meta { |
|
| 43 | + | display: flex; |
|
| 44 | + | justify-content: space-between; |
|
| 45 | + | align-items: center; |
|
| 46 | + | font-size: 12px; |
|
| 47 | + | opacity: 0.5; |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | .feed-source { |
|
| 51 | + | font-weight: 700; |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | .feed-title { |
|
| 55 | + | font-size: 16px; |
|
| 56 | + | font-weight: 400; |
|
| 57 | + | line-height: 1.4; |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | .feed-title a { |
|
| 61 | + | text-decoration: none; |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | .feed-author { |
|
| 65 | + | font-size: 12px; |
|
| 66 | + | opacity: 0.5; |
|
| 67 | + | font-style: italic; |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | #feed-urls { |
|
| 71 | + | font-size: 12px; |
|
| 72 | + | opacity: 0.5; |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | .no-feeds, |
|
| 76 | + | #loading { |
|
| 77 | + | text-align: center; |
|
| 78 | + | opacity: 0.5; |
|
| 79 | + | padding: 2rem; |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | #error { |
|
| 83 | + | text-align: center; |
|
| 84 | + | padding: 2rem; |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /* Admin forms */ |
|
| 88 | + | ||
| 89 | + | .admin-form { |
|
| 90 | + | display: flex; |
|
| 91 | + | flex-direction: column; |
|
| 92 | + | gap: 0.75rem; |
|
| 93 | + | width: 100%; |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | .admin-form h3 { |
|
| 97 | + | font-size: 14px; |
|
| 98 | + | font-weight: 400; |
|
| 99 | + | opacity: 0.5; |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | .admin-notice, |
|
| 103 | + | .hint { |
|
| 104 | + | font-size: 12px; |
|
| 105 | + | opacity: 0.5; |
|
| 106 | + | line-height: 1.4; |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /* Discover panel */ |
|
| 110 | + | ||
| 111 | + | .discover-row { |
|
| 112 | + | display: flex; |
|
| 113 | + | gap: 0.5rem; |
|
| 114 | + | width: 100%; |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | .discover-row input { |
|
| 118 | + | flex: 1; |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | .discover-status { |
|
| 122 | + | font-size: 12px; |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | .discover-results { |
|
| 126 | + | display: flex; |
|
| 127 | + | flex-direction: column; |
|
| 128 | + | gap: 0.25rem; |
|
| 129 | + | width: 100%; |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | .discover-result-item { |
|
| 133 | + | background: #121113; |
|
| 134 | + | color: #ffffff; |
|
| 135 | + | border: 1px solid #333; |
|
| 136 | + | padding: 8px 10px; |
|
| 137 | + | font-size: 12px; |
|
| 138 | + | text-align: left; |
|
| 139 | + | cursor: pointer; |
|
| 140 | + | width: 100%; |
|
| 141 | + | white-space: nowrap; |
|
| 142 | + | overflow: hidden; |
|
| 143 | + | text-overflow: ellipsis; |
|
| 144 | + | opacity: 0.7; |
|
| 145 | + | border-radius: 0; |
|
| 146 | + | -webkit-appearance: none; |
|
| 147 | + | appearance: none; |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | .discover-result-item:hover { |
|
| 151 | + | border-color: #555; |
|
| 152 | + | opacity: 1; |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | .discover-result-item.active { |
|
| 156 | + | border-color: #ffffff; |
|
| 157 | + | opacity: 1; |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /* Admin subs */ |
|
| 161 | + | ||
| 162 | + | .admin-subs { |
|
| 163 | + | width: 100%; |
|
| 164 | + | display: flex; |
|
| 165 | + | flex-direction: column; |
|
| 166 | + | gap: 1rem; |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | .admin-subs h3 { |
|
| 170 | + | font-size: 14px; |
|
| 171 | + | opacity: 0.5; |
|
| 172 | + | font-weight: 400; |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | .feed-item form.inline { |
|
| 176 | + | display: flex; |
|
| 177 | + | gap: 0.5rem; |
|
| 178 | + | align-items: center; |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | .feed-item form.inline input { |
|
| 182 | + | flex: 1; |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | /* Generic .danger on buttons (used in admin) */ |
|
| 186 | + | ||
| 187 | + | button.danger, |
|
| 188 | + | .btn.danger { |
|
| 189 | + | opacity: 0.5; |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | button.danger:hover, |
|
| 193 | + | .btn.danger:hover { |
|
| 194 | + | opacity: 0.3; |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | /* Category list (admin) */ |
|
| 198 | + | ||
| 199 | + | .category-list { |
|
| 200 | + | list-style: none; |
|
| 201 | + | margin-left: 0; |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | .category-list li { |
|
| 205 | + | display: flex; |
|
| 206 | + | justify-content: space-between; |
|
| 207 | + | align-items: center; |
|
| 208 | + | padding: 0.25rem 0; |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | @media (max-width: 480px) { |
|
| 212 | + | .feed-meta { |
|
| 213 | + | flex-direction: column; |
|
| 214 | + | align-items: flex-start; |
|
| 215 | + | gap: 0.25rem; |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | .feed-title { |
|
| 219 | + | font-size: 14px; |
|
| 220 | + | } |
|
| 221 | + | } |
| 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 | + | } |
| 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> |
| 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> |
| 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> |
| 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 | + | } |
| 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= |
| 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"] |
| 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 | + | ``` |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* 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 | + | } |
| 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> |
| 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> |
| 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> |
| 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> |
| 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> |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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{} |
| 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 ¬es[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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 |
| 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"] |
| 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` | |
| 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 | + | } |
| 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, ¬es, &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 | + | } |
| 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: |
| 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 | + | ) |
| 1 | + | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
|
| 2 | + | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
|
| 3 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
|
| 4 | + | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
|
| 5 | + | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
|
| 6 | + | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|
| 7 | + | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|
| 8 | + | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|
| 9 | + | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= |
|
| 10 | + | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
|
| 11 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
|
| 12 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 13 | + | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
|
| 14 | + | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= |
|
| 15 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= |
|
| 16 | + | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= |
|
| 17 | + | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= |
|
| 18 | + | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= |
|
| 19 | + | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= |
|
| 20 | + | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= |
|
| 21 | + | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 22 | + | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= |
|
| 23 | + | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= |
|
| 24 | + | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= |
|
| 25 | + | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= |
|
| 26 | + | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= |
|
| 27 | + | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= |
|
| 28 | + | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= |
|
| 29 | + | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= |
|
| 30 | + | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= |
|
| 31 | + | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= |
|
| 32 | + | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
|
| 33 | + | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
|
| 34 | + | modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= |
|
| 35 | + | modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= |
|
| 36 | + | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
|
| 37 | + | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
|
| 38 | + | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
|
| 39 | + | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
|
| 40 | + | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= |
|
| 41 | + | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
|
| 42 | + | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
|
| 43 | + | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
|
| 44 | + | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= |
|
| 45 | + | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= |
|
| 46 | + | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
|
| 47 | + | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
|
| 48 | + | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
|
| 49 | + | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "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 | + | } |
| 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 | + | } |
| 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 = ¬es |
|
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* 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 | + | } |
| 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 ({'&':'&','<':'<','>':'>','"':'"',"'":"'"})[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> |
| 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> |
| 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> |
| 1 | + | PORT=3000 |
| 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"] |
| 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. |
| 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 | + | } |
| 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 |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* 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 | + | } |
| 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}} |
| 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}} |
| 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}} |
| 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 |
| 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"] |
| 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`. |
| 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 | + | } |
| 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(¤t) |
|
| 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 | + | } |
| 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: |
| 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 | + | ) |
| 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= |
| 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 |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* 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 | + | } |
| 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 | + | } |
| 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}} |
| 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('');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}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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 {{latest_posts}} 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") |
|
| 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 | + | } |
| 1 | + | HOST=127.0.0.1 |
|
| 2 | + | PORT=3000 |
| 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"] |
| 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. |
| 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 | + | } |
| 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 |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* 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 | + | } |
| 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}} |
| 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}} |
| 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 |
| 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"] |
| 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. |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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("&", "&", "<", "<", ">", ">").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("&", "&", "<", "<", ">", ">").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 |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/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"} |
| 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 | + | } |
| 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}} |
| 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}} |
| 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}} |
| 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}} |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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{} |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 1 | + | module github.com/stevedylandev/andromeda/crates-go/auth |
|
| 2 | + | ||
| 3 | + | go 1.24 |
|
| 4 | + | ||
| 5 | + | require golang.org/x/crypto v0.39.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= |
| 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 | + | } |
| 1 | + | module github.com/stevedylandev/andromeda/crates-go/config |
|
| 2 | + | ||
| 3 | + | go 1.24 |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
| 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 & 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> |
| 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 | + | } |
| 1 | + | module github.com/stevedylandev/andromeda/crates-go/darkmatter |
|
| 2 | + | ||
| 3 | + | go 1.24 |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 1 | + | module github.com/stevedylandev/andromeda/crates-go/web |
|
| 2 | + | ||
| 3 | + | go 1.24 |
| 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 | + | } |
| 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. |