| 1 |
1 |
|
--- |
| 2 |
2 |
|
name: Andromeda Stack |
| 3 |
|
- |
description: Scaffold a Rust CRUD web application with Axum, SQLite, Askama templates, andromeda-auth session auth, embedded static assets, and Docker deployment. Use when the user wants to build a new Rust web server with CRUD operations in the andromeda monorepo. |
|
3 |
+ |
description: Scaffold a Go CRUD web app using net/http + html/template + modernc.org/sqlite + the shared andromeda crates-go packages (auth, web, config, sqlite, darkmatter). Use when the user wants to build a new Go web server with CRUD operations in the andromeda monorepo. |
| 4 |
4 |
|
--- |
| 5 |
5 |
|
|
| 6 |
|
- |
# Rust Andromeda Web App |
|
6 |
+ |
# Go Andromeda Web App |
| 7 |
7 |
|
|
| 8 |
8 |
|
## Overview |
| 9 |
9 |
|
|
| 10 |
|
- |
Scaffold and build Rust CRUD web applications in the andromeda workspace using Axum + SQLite + Askama templates + `andromeda-auth` for authentication. The result is a single binary web server with HTML pages, a JSON API, optional session or API key auth, and Docker deployment. |
|
10 |
+ |
Scaffold and build Go CRUD web apps in the andromeda workspace using the |
|
11 |
+ |
standard library `net/http` mux, `html/template`, `modernc.org/sqlite` (pure |
|
12 |
+ |
Go, no cgo) and the shared `crates-go/*` packages. Each app ships a single Go |
|
13 |
+ |
binary with HTML pages, a JSON API, optional session or API key auth, embedded |
|
14 |
+ |
templates + static assets via `embed.FS`, and Docker deployment. |
|
15 |
+ |
|
|
16 |
+ |
Apps live under `apps/<name>/`. Each app is its own Go module. Shared packages |
|
17 |
+ |
live under `crates-go/` and are each their own module, wired in via local |
|
18 |
+ |
`replace` directives. |
| 11 |
19 |
|
|
| 12 |
|
- |
All apps live under `apps/` in the workspace and share dependencies via the root `Cargo.toml`. Shared crates live under `crates/`: |
|
20 |
+ |
Shared crates: |
| 13 |
21 |
|
|
| 14 |
|
- |
- `andromeda-auth` — password verify, session token, cookie helpers |
| 15 |
|
- |
- `andromeda-db` — DB connection helpers, optional `session` feature |
| 16 |
|
- |
- `andromeda-darkmatter-css` — shared CSS theme |
|
22 |
+ |
- `crates-go/auth` — session `Store`, `RequireSession` / `RequireAPIKey` / |
|
23 |
+ |
`RequireBearerOrSession` middleware, `SecureEqual`, `VerifyPassword` |
|
24 |
+ |
(bcrypt or plaintext), `GenerateSessionToken`, `GenerateShortID`. |
|
25 |
+ |
- `crates-go/web` — `Render`, `WriteJSON`, `WriteError`, `DecodeJSON`, |
|
26 |
+ |
`EmbeddedHandler`, `RedirectWithError`, `RedirectWithSuccess`, `PathInt64`. |
|
27 |
+ |
- `crates-go/config` — `LoadDotEnv`, `Getenv`, `GetenvInt`, `GetenvBool`. |
|
28 |
+ |
- `crates-go/sqlite` — `Open(path, schema)` opens SQLite with the project |
|
29 |
+ |
defaults (`PRAGMA foreign_keys=ON`, `SetMaxOpenConns(1)`). |
|
30 |
+ |
- `crates-go/darkmatter` — embedded shared CSS + fonts plus `Mount(mux, |
|
31 |
+ |
"/assets")` to register routes on any `*http.ServeMux`. |
| 17 |
32 |
|
|
| 18 |
33 |
|
## Project Structure |
| 19 |
34 |
|
|
| 20 |
35 |
|
``` |
| 21 |
36 |
|
apps/app-name/ |
| 22 |
|
- |
├── src/ |
| 23 |
|
- |
│ ├── main.rs # Entry point, starts the server |
| 24 |
|
- |
│ ├── server.rs # Axum routes, middleware, handlers, static asset serving |
| 25 |
|
- |
│ ├── db.rs # SQLite schema, CRUD functions, error types |
| 26 |
|
- |
│ └── auth.rs # Session/cookie auth wrapper (uses andromeda-auth crate) |
| 27 |
|
- |
├── templates/ # Askama HTML templates |
| 28 |
|
- |
│ ├── base.html # Base layout with blocks (title, content) |
| 29 |
|
- |
│ └── *.html # Pages extend base.html |
| 30 |
|
- |
├── static/ # CSS, fonts, favicons (served via tower-http ServeDir) |
| 31 |
|
- |
│ └── styles.css |
| 32 |
|
- |
├── .env.example # Environment variable reference |
| 33 |
|
- |
├── Dockerfile # Multi-stage workspace build |
| 34 |
|
- |
└── docker-compose.yml # Compose config with volume for DB persistence |
|
37 |
+ |
├── main.go # entry point; loads env, opens DB, builds App, starts server |
|
38 |
+ |
├── app.go # App struct + embed.FS + page-data structs |
|
39 |
+ |
├── routes.go # *http.ServeMux wiring + middleware |
|
40 |
+ |
├── db.go # schema, model, CRUD funcs (or thin wrapper around internal/store) |
|
41 |
+ |
├── handlers_web.go # HTML handlers (login, index, CRUD pages) |
|
42 |
+ |
├── handlers_api.go # JSON API handlers |
|
43 |
+ |
├── templates/ # html/template files |
|
44 |
+ |
│ ├── base.html # layout with {{ block "title" . }} / {{ block "content" . }} |
|
45 |
+ |
│ └── *.html # pages that {{ template "base" . }} or define blocks |
|
46 |
+ |
├── static/ # CSS, JS, images embedded via embed.FS |
|
47 |
+ |
├── .env.example |
|
48 |
+ |
├── Dockerfile # multi-stage Go build, built from repo root |
|
49 |
+ |
├── docker-compose.yml # app-local dev compose |
|
50 |
+ |
├── go.mod / go.sum |
|
51 |
+ |
└── README.md |
| 35 |
52 |
|
``` |
| 36 |
53 |
|
|
| 37 |
|
- |
## Dependencies (Cargo.toml) |
|
54 |
+ |
Apps with extra surface (e.g. `jotts` has `tui/` + `cmd_*.go` subcommands, |
|
55 |
+ |
`feeds` has `subscriptions.go` + background poller) layer files on top of this |
|
56 |
+ |
shape; do not invent new layers unless the app needs them. |
|
57 |
+ |
|
|
58 |
+ |
## go.mod |
|
59 |
+ |
|
|
60 |
+ |
Module path: `github.com/stevedylandev/andromeda/apps/<app-name>`. Pin |
|
61 |
+ |
`go 1.25` (match other apps). Pull the shared crates by their canonical paths |
|
62 |
+ |
and add `replace` directives for the local checkout: |
|
63 |
+ |
|
|
64 |
+ |
```go |
|
65 |
+ |
module github.com/stevedylandev/andromeda/apps/APP_NAME |
| 38 |
66 |
|
|
| 39 |
|
- |
Apps use workspace dependencies from the root `Cargo.toml`. Use `{ workspace = true }` for shared crates: |
|
67 |
+ |
go 1.25 |
| 40 |
68 |
|
|
| 41 |
|
- |
```toml |
| 42 |
|
- |
[package] |
| 43 |
|
- |
name = "app-name" |
| 44 |
|
- |
version = "0.1.0" |
| 45 |
|
- |
edition = "2024" |
| 46 |
|
- |
description = "Short app description" |
| 47 |
|
- |
license = "MIT" |
| 48 |
|
- |
repository = "https://github.com/stevedylandev/andromeda" |
| 49 |
|
- |
homepage = "https://github.com/stevedylandev/andromeda" |
|
69 |
+ |
require ( |
|
70 |
+ |
github.com/stevedylandev/andromeda/crates-go/auth v0.0.0 |
|
71 |
+ |
github.com/stevedylandev/andromeda/crates-go/config v0.0.0 |
|
72 |
+ |
github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0 |
|
73 |
+ |
github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0 |
|
74 |
+ |
github.com/stevedylandev/andromeda/crates-go/web v0.0.0 |
|
75 |
+ |
) |
| 50 |
76 |
|
|
| 51 |
|
- |
[dependencies] |
| 52 |
|
- |
axum = { workspace = true } |
| 53 |
|
- |
tokio = { workspace = true } |
| 54 |
|
- |
serde = { workspace = true } |
| 55 |
|
- |
serde_json = { workspace = true } |
| 56 |
|
- |
rusqlite = { workspace = true } |
| 57 |
|
- |
nanoid = { workspace = true } |
| 58 |
|
- |
rust-embed = { workspace = true } |
| 59 |
|
- |
dotenvy = { workspace = true } |
| 60 |
|
- |
subtle = { workspace = true } |
| 61 |
|
- |
rand = { workspace = true } |
| 62 |
|
- |
tracing = { workspace = true } |
| 63 |
|
- |
tracing-subscriber = { workspace = true } |
| 64 |
|
- |
andromeda-auth = { workspace = true } |
| 65 |
|
- |
andromeda-db = { workspace = true, features = ["session"] } # if using session auth |
| 66 |
|
- |
andromeda-darkmatter-css = { workspace = true } # if using shared theme |
| 67 |
|
- |
askama = "0.15" |
| 68 |
|
- |
askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
77 |
+ |
replace ( |
|
78 |
+ |
github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth |
|
79 |
+ |
github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config |
|
80 |
+ |
github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter |
|
81 |
+ |
github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite |
|
82 |
+ |
github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web |
|
83 |
+ |
) |
| 69 |
84 |
|
``` |
| 70 |
85 |
|
|
| 71 |
|
- |
After creating the app, register it in the workspace root `Cargo.toml` under `[workspace] members`. See **Wiring up a new app** below for the full set of workspace files to update. |
|
86 |
+ |
Add `crates-go/tui` only if the app ships a TUI. Add `golang.org/x/crypto`, |
|
87 |
+ |
`yuin/goldmark`, etc. only when the specific app needs them. The Dockerfile |
|
88 |
+ |
must copy `crates-go/` into the build context for the replace directives. |
| 72 |
89 |
|
|
| 73 |
|
- |
Only add additional crates when the specific app requires them. Do NOT include TUI crates (ratatui, crossterm), CLI crates (clap), or HTTP client crates (reqwest) unless explicitly requested. |
|
90 |
+ |
Do NOT add ORMs, web frameworks (gin, echo, chi), connection pools, or HTTP |
|
91 |
+ |
client libs unless explicitly requested. The stdlib `net/http` mux (with |
|
92 |
+ |
method-prefixed patterns like `GET /notes/{short_id}`) is sufficient. |
| 74 |
93 |
|
|
| 75 |
|
- |
- `tracing` + `tracing-subscriber` — structured logging, always include |
| 76 |
|
- |
- `rand` — needed for session token generation when using session-based auth |
| 77 |
|
- |
- `tower-http` — for serving static files from disk via `ServeDir` |
|
94 |
+ |
## main.go |
|
95 |
+ |
|
|
96 |
+ |
Minimal — loads env, sets up logging + DB + sessions, starts the server. |
|
97 |
+ |
|
|
98 |
+ |
```go |
|
99 |
+ |
package main |
| 78 |
100 |
|
|
| 79 |
|
- |
## Database Layer (db.rs) |
|
101 |
+ |
import ( |
|
102 |
+ |
"html/template" |
|
103 |
+ |
"log" |
|
104 |
+ |
"log/slog" |
|
105 |
+ |
"net/http" |
|
106 |
+ |
"os" |
| 80 |
107 |
|
|
| 81 |
|
- |
Pattern: single-file module with `Arc<Mutex<Connection>>` for thread-safe SQLite access. |
|
108 |
+ |
"github.com/stevedylandev/andromeda/crates-go/auth" |
|
109 |
+ |
"github.com/stevedylandev/andromeda/crates-go/config" |
|
110 |
+ |
) |
| 82 |
111 |
|
|
| 83 |
|
- |
### Structure |
|
112 |
+ |
func main() { |
|
113 |
+ |
config.LoadDotEnv(".env") |
|
114 |
+ |
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) |
| 84 |
115 |
|
|
| 85 |
|
- |
```rust |
| 86 |
|
- |
use nanoid::nanoid; |
| 87 |
|
- |
use rusqlite::{Connection, params}; |
| 88 |
|
- |
use serde::{Deserialize, Serialize}; |
| 89 |
|
- |
use std::fmt; |
| 90 |
|
- |
use std::sync::{Arc, Mutex}; |
|
116 |
+ |
dbPath := config.Getenv("APP_DB_PATH", "app.sqlite") |
|
117 |
+ |
db, err := openDB(dbPath) |
|
118 |
+ |
if err != nil { |
|
119 |
+ |
log.Fatal(err) |
|
120 |
+ |
} |
|
121 |
+ |
defer db.Close() |
| 91 |
122 |
|
|
| 92 |
|
- |
pub type Db = Arc<Mutex<Connection>>; |
|
123 |
+ |
sessions := &auth.Store{ |
|
124 |
+ |
DB: db, |
|
125 |
+ |
CookieName: "session", |
|
126 |
+ |
CookieSecure: config.GetenvBool("COOKIE_SECURE", false), |
|
127 |
+ |
} |
|
128 |
+ |
if err := sessions.EnsureSchema(); err != nil { |
|
129 |
+ |
log.Fatal(err) |
|
130 |
+ |
} |
|
131 |
+ |
sessions.PruneExpired() |
| 93 |
132 |
|
|
| 94 |
|
- |
#[derive(Debug)] |
| 95 |
|
- |
pub enum DbError { |
| 96 |
|
- |
Sqlite(rusqlite::Error), |
| 97 |
|
- |
LockPoisoned, |
| 98 |
|
- |
} |
|
133 |
+ |
tmpl := template.Must(template.ParseFS(appFS, "templates/*.html")) |
| 99 |
134 |
|
|
| 100 |
|
- |
impl fmt::Display for DbError { |
| 101 |
|
- |
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 102 |
|
- |
match self { |
| 103 |
|
- |
DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
| 104 |
|
- |
DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
| 105 |
|
- |
} |
| 106 |
|
- |
} |
| 107 |
|
- |
} |
|
135 |
+ |
password := os.Getenv("APP_PASSWORD") |
|
136 |
+ |
if password == "" { |
|
137 |
+ |
logger.Warn("APP_PASSWORD not set, using default 'changeme'") |
|
138 |
+ |
password = "changeme" |
|
139 |
+ |
} |
|
140 |
+ |
apiKey := os.Getenv("APP_API_KEY") |
|
141 |
+ |
if apiKey == "" { |
|
142 |
+ |
logger.Info("APP_API_KEY not set, /api/* will return 403") |
|
143 |
+ |
} |
| 108 |
144 |
|
|
| 109 |
|
- |
impl std::error::Error for DbError {} |
|
145 |
+ |
app := &App{ |
|
146 |
+ |
DB: db, |
|
147 |
+ |
Log: logger, |
|
148 |
+ |
Templates: tmpl, |
|
149 |
+ |
Sessions: sessions, |
|
150 |
+ |
Password: password, |
|
151 |
+ |
APIKey: apiKey, |
|
152 |
+ |
CookieSecure: sessions.CookieSecure, |
|
153 |
+ |
} |
| 110 |
154 |
|
|
| 111 |
|
- |
impl From<rusqlite::Error> for DbError { |
| 112 |
|
- |
fn from(e: rusqlite::Error) -> Self { |
| 113 |
|
- |
DbError::Sqlite(e) |
| 114 |
|
- |
} |
|
155 |
+ |
addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000") |
|
156 |
+ |
logger.Info("APP_NAME server running", "addr", addr) |
|
157 |
+ |
if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
158 |
+ |
log.Fatal(err) |
|
159 |
+ |
} |
| 115 |
160 |
|
} |
| 116 |
161 |
|
``` |
| 117 |
162 |
|
|
| 118 |
|
- |
### Key patterns |
|
163 |
+ |
Apps that also expose CLI subcommands (e.g. `jotts`, `sipp`) keep `main.go` |
|
164 |
+ |
as a dispatcher and move `runServer` into `cmd_server.go`. |
| 119 |
165 |
|
|
| 120 |
|
- |
- **Model struct**: derive `Serialize, Deserialize`, all fields `pub` |
| 121 |
|
- |
- **ID generation**: `nanoid!(10)` for short unique IDs |
| 122 |
|
- |
- **DB path from env**: `std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())` |
| 123 |
|
- |
- **init_db()**: opens connection, runs `CREATE TABLE IF NOT EXISTS`, returns `Arc<Mutex<Connection>>` |
| 124 |
|
- |
- **CRUD functions**: standalone functions that take `&Db` as first param |
| 125 |
|
- |
- `create_*` — INSERT, return created model with `last_insert_rowid()` |
| 126 |
|
- |
- `get_*_by_short_id` — SELECT, return `Result<Option<Model>, DbError>` |
| 127 |
|
- |
- `get_all_*` — SELECT with ORDER BY id DESC |
| 128 |
|
- |
- `delete_*_by_short_id` — DELETE, return `Result<bool, DbError>` (rows_affected > 0) |
| 129 |
|
- |
- `update_*_by_short_id` — UPDATE then SELECT to return updated model |
| 130 |
|
- |
- **Error handling**: `QueryReturnedNoRows` maps to `Ok(None)`, not an error |
|
166 |
+ |
## app.go — App struct + embed |
| 131 |
167 |
|
|
| 132 |
|
- |
## Server Layer (server.rs) |
|
168 |
+ |
Holds dependencies, embedded FS, and per-page data structs. |
| 133 |
169 |
|
|
| 134 |
|
- |
### Embedded assets with rust_embed |
|
170 |
+ |
```go |
|
171 |
+ |
package main |
| 135 |
172 |
|
|
| 136 |
|
- |
```rust |
| 137 |
|
- |
use rust_embed::Embed; |
|
173 |
+ |
import ( |
|
174 |
+ |
"database/sql" |
|
175 |
+ |
"embed" |
|
176 |
+ |
"html/template" |
|
177 |
+ |
"log/slog" |
| 138 |
178 |
|
|
| 139 |
|
- |
#[derive(Embed)] |
| 140 |
|
- |
#[folder = "assets/"] |
| 141 |
|
- |
struct Assets; |
|
179 |
+ |
"github.com/stevedylandev/andromeda/crates-go/auth" |
|
180 |
+ |
) |
| 142 |
181 |
|
|
| 143 |
|
- |
#[derive(Embed)] |
| 144 |
|
- |
#[folder = "static/"] |
| 145 |
|
- |
struct Static; |
| 146 |
|
- |
``` |
|
182 |
+ |
//go:embed templates/*.html static/* |
|
183 |
+ |
var appFS embed.FS |
| 147 |
184 |
|
|
| 148 |
|
- |
Serve with handlers that match on file path and return correct MIME types: |
| 149 |
|
- |
|
| 150 |
|
- |
```rust |
| 151 |
|
- |
fn mime_from_path(path: &str) -> &'static str { |
| 152 |
|
- |
match path.rsplit('.').next().unwrap_or("") { |
| 153 |
|
- |
"css" => "text/css", |
| 154 |
|
- |
"js" => "application/javascript", |
| 155 |
|
- |
"html" => "text/html", |
| 156 |
|
- |
"png" => "image/png", |
| 157 |
|
- |
"ico" => "image/x-icon", |
| 158 |
|
- |
"svg" => "image/svg+xml", |
| 159 |
|
- |
"woff" | "woff2" => "font/woff2", |
| 160 |
|
- |
"ttf" => "font/ttf", |
| 161 |
|
- |
"otf" => "font/otf", |
| 162 |
|
- |
"json" | "webmanifest" => "application/json", |
| 163 |
|
- |
_ => "application/octet-stream", |
| 164 |
|
- |
} |
|
185 |
+ |
type App struct { |
|
186 |
+ |
DB *sql.DB |
|
187 |
+ |
Log *slog.Logger |
|
188 |
+ |
Templates *template.Template |
|
189 |
+ |
Sessions *auth.Store |
|
190 |
+ |
Password string |
|
191 |
+ |
APIKey string |
|
192 |
+ |
CookieSecure bool |
| 165 |
193 |
|
} |
| 166 |
|
- |
``` |
| 167 |
194 |
|
|
| 168 |
|
- |
### App state |
|
195 |
+ |
type indexPageData struct { |
|
196 |
+ |
Items []Item |
|
197 |
+ |
} |
| 169 |
198 |
|
|
| 170 |
|
- |
```rust |
| 171 |
|
- |
#[derive(Clone)] |
| 172 |
|
- |
struct AppState { |
| 173 |
|
- |
db: Db, |
| 174 |
|
- |
server_config: ServerConfig, |
|
199 |
+ |
type loginPageData struct { |
|
200 |
+ |
Error string |
| 175 |
201 |
|
} |
| 176 |
202 |
|
``` |
| 177 |
203 |
|
|
| 178 |
|
- |
Add domain-specific fields as needed (e.g., a highlighter, cache, etc). |
|
204 |
+ |
Page-data structs go here too. Keep names like `<page>PageData`. |
| 179 |
205 |
|
|
| 180 |
|
- |
### Askama templates |
|
206 |
+ |
## routes.go |
| 181 |
207 |
|
|
| 182 |
|
- |
```rust |
| 183 |
|
- |
use askama::Template; |
| 184 |
|
- |
use askama_web::WebTemplate; |
|
208 |
+ |
Wire routes on a `*http.ServeMux` using method-prefixed patterns. Mount |
|
209 |
+ |
darkmatter assets and static files. Wrap protected routes with |
|
210 |
+ |
`Sessions.RequireSession` / `auth.RequireAPIKey`. |
| 185 |
211 |
|
|
| 186 |
|
- |
#[derive(Template)] |
| 187 |
|
- |
#[template(path = "index.html")] |
| 188 |
|
- |
struct IndexTemplate; |
|
212 |
+ |
```go |
|
213 |
+ |
package main |
| 189 |
214 |
|
|
| 190 |
|
- |
// Templates with data: |
| 191 |
|
- |
#[derive(Template)] |
| 192 |
|
- |
#[template(path = "item.html")] |
| 193 |
|
- |
struct ItemTemplate { |
| 194 |
|
- |
name: String, |
| 195 |
|
- |
content: String, |
| 196 |
|
- |
} |
| 197 |
|
- |
``` |
|
215 |
+ |
import ( |
|
216 |
+ |
"net/http" |
| 198 |
217 |
|
|
| 199 |
|
- |
Render with `WebTemplate(MyTemplate { ... })`. |
|
218 |
+ |
"github.com/stevedylandev/andromeda/crates-go/auth" |
|
219 |
+ |
"github.com/stevedylandev/andromeda/crates-go/darkmatter" |
|
220 |
+ |
"github.com/stevedylandev/andromeda/crates-go/web" |
|
221 |
+ |
) |
| 200 |
222 |
|
|
| 201 |
|
- |
### Route structure |
|
223 |
+ |
func (a *App) routes() *http.ServeMux { |
|
224 |
+ |
mux := http.NewServeMux() |
| 202 |
225 |
|
|
| 203 |
|
- |
Two sets of routes: **web routes** (HTML pages + form submissions) and **API routes** (JSON). |
|
226 |
+ |
mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static")) |
|
227 |
+ |
darkmatter.Mount(mux, "/assets") |
| 204 |
228 |
|
|
| 205 |
|
- |
**Web routes:** |
| 206 |
|
- |
- `GET /` — index page (template) |
| 207 |
|
- |
- `GET /admin` — admin panel (template) |
| 208 |
|
- |
- `POST /items` — form submission, redirects on success |
| 209 |
|
- |
- `GET /items/{short_id}` — view single item (template) |
|
229 |
+ |
requireSession := func(next http.HandlerFunc) http.HandlerFunc { |
|
230 |
+ |
return a.Sessions.RequireSession("/login", next) |
|
231 |
+ |
} |
|
232 |
+ |
requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc { |
|
233 |
+ |
return auth.RequireAPIKey(a.APIKey, next) |
|
234 |
+ |
} |
| 210 |
235 |
|
|
| 211 |
|
- |
**API routes:** |
| 212 |
|
- |
- `GET /api/items` — list all (JSON) |
| 213 |
|
- |
- `POST /api/items` — create (JSON body → 201 + JSON) |
| 214 |
|
- |
- `GET /api/items/{short_id}` — get one (JSON) |
| 215 |
|
- |
- `PUT /api/items/{short_id}` — update (JSON body → JSON) |
| 216 |
|
- |
- `DELETE /api/items/{short_id}` — delete (JSON) |
|
236 |
+ |
mux.HandleFunc("GET /login", a.loginGetHandler) |
|
237 |
+ |
mux.HandleFunc("POST /login", a.loginPostHandler) |
|
238 |
+ |
mux.HandleFunc("GET /logout", a.logoutHandler) |
| 217 |
239 |
|
|
| 218 |
|
- |
**Static asset routes:** |
| 219 |
|
- |
- `GET /assets/{*path}` — embedded assets (favicons, fonts, images) |
| 220 |
|
- |
- `GET /static/{*path}` — embedded static files (CSS) |
|
240 |
+ |
mux.HandleFunc("GET /{$}", requireSession(a.indexHandler)) |
|
241 |
+ |
mux.HandleFunc("GET /items/new", requireSession(a.newItemGetHandler)) |
|
242 |
+ |
mux.HandleFunc("POST /items", requireSession(a.createItemHandler)) |
|
243 |
+ |
mux.HandleFunc("GET /items/{short_id}", requireSession(a.viewItemHandler)) |
|
244 |
+ |
mux.HandleFunc("GET /items/{short_id}/edit", requireSession(a.editItemGetHandler)) |
|
245 |
+ |
mux.HandleFunc("POST /items/{short_id}", requireSession(a.updateItemHandler)) |
|
246 |
+ |
mux.HandleFunc("POST /items/{short_id}/delete", requireSession(a.deleteItemHandler)) |
| 221 |
247 |
|
|
| 222 |
|
- |
### Form deserialization |
|
248 |
+ |
mux.HandleFunc("GET /api/items", requireAPIKey(a.apiListItems)) |
|
249 |
+ |
mux.HandleFunc("POST /api/items", requireAPIKey(a.apiCreateItem)) |
|
250 |
+ |
mux.HandleFunc("GET /api/items/{short_id}", requireAPIKey(a.apiGetItem)) |
|
251 |
+ |
mux.HandleFunc("PUT /api/items/{short_id}", requireAPIKey(a.apiUpdateItem)) |
|
252 |
+ |
mux.HandleFunc("DELETE /api/items/{short_id}", requireAPIKey(a.apiDeleteItem)) |
| 223 |
253 |
|
|
| 224 |
|
- |
```rust |
| 225 |
|
- |
#[derive(Deserialize)] |
| 226 |
|
- |
struct CreateItemForm { |
| 227 |
|
- |
name: String, |
| 228 |
|
- |
content: String, |
|
254 |
+ |
return mux |
| 229 |
255 |
|
} |
| 230 |
256 |
|
``` |
| 231 |
257 |
|
|
| 232 |
|
- |
Use `Form(form): Form<CreateItemForm>` for HTML forms, `Json(body): Json<CreateItem>` for API. |
|
258 |
+ |
Notes: |
|
259 |
+ |
- `GET /{$}` matches exactly `/` (no prefix matching). |
|
260 |
+ |
- Path params extracted with `r.PathValue("short_id")`. |
|
261 |
+ |
- For HTML forms (no JS), delete via `POST /items/{short_id}/delete`. The |
|
262 |
+ |
`/api/*` routes use real `DELETE`/`PUT`. |
| 233 |
263 |
|
|
| 234 |
|
- |
### Error responses |
|
264 |
+ |
## db.go (or internal/store) |
| 235 |
265 |
|
|
| 236 |
|
- |
- Web handlers return `Result<..., (StatusCode, Html<String>)>` |
| 237 |
|
- |
- API handlers return `Result<..., (StatusCode, Json<serde_json::Value>)>` |
| 238 |
|
- |
- Use `serde_json::json!({"error": "message"})` for API error bodies |
|
266 |
+ |
Single-file module: schema, model, CRUD. `crates-go/sqlite.Open` handles the |
|
267 |
+ |
driver, pragmas, and schema bootstrap. |
| 239 |
268 |
|
|
| 240 |
|
- |
## Authentication |
|
269 |
+ |
```go |
|
270 |
+ |
package main |
| 241 |
271 |
|
|
| 242 |
|
- |
The workspace provides a shared `andromeda-auth` crate at `crates/auth/` with these primitives: |
|
272 |
+ |
import ( |
|
273 |
+ |
"database/sql" |
|
274 |
+ |
"errors" |
| 243 |
275 |
|
|
| 244 |
|
- |
```rust |
| 245 |
|
- |
// andromeda-auth public API |
| 246 |
|
- |
pub fn verify_password(input: &str, expected: &str) -> bool; |
| 247 |
|
- |
pub fn generate_session_token() -> String; |
| 248 |
|
- |
pub fn build_session_cookie(token: &str, secure: bool) -> String; |
| 249 |
|
- |
pub fn clear_session_cookie() -> String; |
| 250 |
|
- |
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String>; |
| 251 |
|
- |
``` |
| 252 |
|
- |
|
| 253 |
|
- |
Apps import `andromeda-auth` via workspace dependency and wrap it in a local `auth.rs` module. |
|
276 |
+ |
"github.com/stevedylandev/andromeda/crates-go/auth" |
|
277 |
+ |
"github.com/stevedylandev/andromeda/crates-go/sqlite" |
|
278 |
+ |
) |
| 254 |
279 |
|
|
| 255 |
|
- |
### Session/cookie auth (for web-facing apps) |
|
280 |
+ |
type Item struct { |
|
281 |
+ |
ID int64 `json:"id"` |
|
282 |
+ |
ShortID string `json:"short_id"` |
|
283 |
+ |
Title string `json:"title"` |
|
284 |
+ |
Content string `json:"content"` |
|
285 |
+ |
CreatedAt string `json:"created_at"` |
|
286 |
+ |
UpdatedAt string `json:"updated_at"` |
|
287 |
+ |
} |
| 256 |
288 |
|
|
| 257 |
|
- |
The standard pattern for apps that need login (e.g., jotts, parcels, feeds). Create a `src/auth.rs` that wraps the auth crate with app-specific session storage and an Axum extractor. |
|
289 |
+ |
type ItemInput struct { |
|
290 |
+ |
Title string `json:"title"` |
|
291 |
+ |
Content string `json:"content"` |
|
292 |
+ |
} |
| 258 |
293 |
|
|
| 259 |
|
- |
**Sessions table in db.rs:** |
|
294 |
+ |
const itemColumns = `id, short_id, title, content, created_at, updated_at` |
| 260 |
295 |
|
|
| 261 |
|
- |
```sql |
| 262 |
|
- |
CREATE TABLE IF NOT EXISTS sessions ( |
|
296 |
+ |
const schema = ` |
|
297 |
+ |
CREATE TABLE IF NOT EXISTS items ( |
| 263 |
298 |
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 264 |
|
- |
token TEXT NOT NULL UNIQUE, |
| 265 |
|
- |
expires_at TEXT NOT NULL |
|
299 |
+ |
short_id TEXT NOT NULL UNIQUE, |
|
300 |
+ |
title TEXT NOT NULL, |
|
301 |
+ |
content TEXT NOT NULL, |
|
302 |
+ |
created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
303 |
+ |
updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 266 |
304 |
|
); |
| 267 |
|
- |
``` |
|
305 |
+ |
` |
| 268 |
306 |
|
|
| 269 |
|
- |
**Session DB functions in db.rs:** |
|
307 |
+ |
func openDB(path string) (*sql.DB, error) { return sqlite.Open(path, schema) } |
| 270 |
308 |
|
|
| 271 |
|
- |
```rust |
| 272 |
|
- |
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
| 273 |
|
- |
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 274 |
|
- |
conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?; |
| 275 |
|
- |
Ok(()) |
|
309 |
+ |
func scanItem(s interface{ Scan(...any) error }) (*Item, error) { |
|
310 |
+ |
var it Item |
|
311 |
+ |
err := s.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt) |
|
312 |
+ |
if errors.Is(err, sql.ErrNoRows) { |
|
313 |
+ |
return nil, nil |
|
314 |
+ |
} |
|
315 |
+ |
if err != nil { |
|
316 |
+ |
return nil, err |
|
317 |
+ |
} |
|
318 |
+ |
return &it, nil |
| 276 |
319 |
|
} |
| 277 |
320 |
|
|
| 278 |
|
- |
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
| 279 |
|
- |
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 280 |
|
- |
match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) { |
| 281 |
|
- |
Ok(val) => Ok(Some(val)), |
| 282 |
|
- |
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
| 283 |
|
- |
Err(e) => Err(DbError::Sqlite(e)), |
| 284 |
|
- |
} |
|
321 |
+ |
func createItem(db *sql.DB, title, content string) (*Item, error) { |
|
322 |
+ |
shortID, err := auth.GenerateShortID(10) |
|
323 |
+ |
if err != nil { |
|
324 |
+ |
return nil, err |
|
325 |
+ |
} |
|
326 |
+ |
res, err := db.Exec(`INSERT INTO items (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content) |
|
327 |
+ |
if err != nil { |
|
328 |
+ |
return nil, err |
|
329 |
+ |
} |
|
330 |
+ |
id, err := res.LastInsertId() |
|
331 |
+ |
if err != nil { |
|
332 |
+ |
return nil, err |
|
333 |
+ |
} |
|
334 |
+ |
return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE id = ?`, id)) |
| 285 |
335 |
|
} |
| 286 |
336 |
|
|
| 287 |
|
- |
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
| 288 |
|
- |
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 289 |
|
- |
conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
| 290 |
|
- |
Ok(()) |
|
337 |
+ |
func getItemByShortID(db *sql.DB, shortID string) (*Item, error) { |
|
338 |
+ |
return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE short_id = ?`, shortID)) |
| 291 |
339 |
|
} |
| 292 |
340 |
|
|
| 293 |
|
- |
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
| 294 |
|
- |
let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
| 295 |
|
- |
conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?; |
| 296 |
|
- |
Ok(()) |
|
341 |
+ |
func listItems(db *sql.DB) ([]Item, error) { |
|
342 |
+ |
rows, err := db.Query(`SELECT ` + itemColumns + ` FROM items ORDER BY id DESC`) |
|
343 |
+ |
if err != nil { |
|
344 |
+ |
return nil, err |
|
345 |
+ |
} |
|
346 |
+ |
defer rows.Close() |
|
347 |
+ |
var out []Item |
|
348 |
+ |
for rows.Next() { |
|
349 |
+ |
var it Item |
|
350 |
+ |
if err := rows.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt); err != nil { |
|
351 |
+ |
return nil, err |
|
352 |
+ |
} |
|
353 |
+ |
out = append(out, it) |
|
354 |
+ |
} |
|
355 |
+ |
return out, rows.Err() |
|
356 |
+ |
} |
|
357 |
+ |
|
|
358 |
+ |
func updateItemByShortID(db *sql.DB, shortID, title, content string) (*Item, error) { |
|
359 |
+ |
res, err := db.Exec(`UPDATE items SET title=?, content=?, updated_at=datetime('now') WHERE short_id=?`, title, content, shortID) |
|
360 |
+ |
if err != nil { |
|
361 |
+ |
return nil, err |
|
362 |
+ |
} |
|
363 |
+ |
if n, _ := res.RowsAffected(); n == 0 { |
|
364 |
+ |
return nil, nil |
|
365 |
+ |
} |
|
366 |
+ |
return getItemByShortID(db, shortID) |
|
367 |
+ |
} |
|
368 |
+ |
|
|
369 |
+ |
func deleteItemByShortID(db *sql.DB, shortID string) (bool, error) { |
|
370 |
+ |
res, err := db.Exec(`DELETE FROM items WHERE short_id = ?`, shortID) |
|
371 |
+ |
if err != nil { |
|
372 |
+ |
return false, err |
|
373 |
+ |
} |
|
374 |
+ |
n, _ := res.RowsAffected() |
|
375 |
+ |
return n > 0, nil |
| 297 |
376 |
|
} |
| 298 |
377 |
|
``` |
| 299 |
378 |
|
|
| 300 |
|
- |
**auth.rs module — wraps andromeda-auth with session validation:** |
|
379 |
+ |
Conventions: |
|
380 |
+ |
- Short IDs via `auth.GenerateShortID(10)`. |
|
381 |
+ |
- Model fields use `json:"..."` tags so the same struct serializes for the API. |
|
382 |
+ |
- "Not found" returns `(nil, nil)`, not an error — handlers check `if x == nil`. |
|
383 |
+ |
- DB path comes from `<APP>_DB_PATH` env (e.g. `JOTTS_DB_PATH`). |
|
384 |
+ |
- For more complex apps, move this into `internal/store/` and keep `db.go` as |
|
385 |
+ |
thin pass-through wrappers (see `apps/jotts`). |
| 301 |
386 |
|
|
| 302 |
|
- |
```rust |
| 303 |
|
- |
use axum::{ |
| 304 |
|
- |
extract::{FromRef, FromRequestParts}, |
| 305 |
|
- |
http::request::Parts, |
| 306 |
|
- |
response::{IntoResponse, Redirect, Response}, |
| 307 |
|
- |
}; |
| 308 |
|
- |
use std::sync::Arc; |
|
387 |
+ |
## handlers_web.go |
| 309 |
388 |
|
|
| 310 |
|
- |
use crate::AppState; |
|
389 |
+ |
HTML handlers. Use `web.Render` for templates and `web.RedirectWithError` for |
|
390 |
+ |
flash-style errors via query params. |
| 311 |
391 |
|
|
| 312 |
|
- |
/// Axum extractor — guards routes behind login. Redirects to /login if invalid. |
| 313 |
|
- |
pub struct AuthSession; |
|
392 |
+ |
```go |
|
393 |
+ |
package main |
| 314 |
394 |
|
|
| 315 |
|
- |
impl<S> FromRequestParts<S> for AuthSession |
| 316 |
|
- |
where |
| 317 |
|
- |
S: Send + Sync, |
| 318 |
|
- |
Arc<AppState>: FromRef<S>, |
| 319 |
|
- |
{ |
| 320 |
|
- |
type Rejection = Response; |
|
395 |
+ |
import ( |
|
396 |
+ |
"net/http" |
|
397 |
+ |
"strings" |
| 321 |
398 |
|
|
| 322 |
|
- |
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
| 323 |
|
- |
let state = Arc::<AppState>::from_ref(state); |
| 324 |
|
- |
let token = andromeda_auth::extract_session_cookie(&parts.headers); |
| 325 |
|
- |
if let Some(token) = token { |
| 326 |
|
- |
if is_valid_session(&state, &token) { |
| 327 |
|
- |
return Ok(AuthSession); |
| 328 |
|
- |
} |
| 329 |
|
- |
} |
| 330 |
|
- |
Err(Redirect::to("/login").into_response()) |
| 331 |
|
- |
} |
| 332 |
|
- |
} |
|
399 |
+ |
"github.com/stevedylandev/andromeda/crates-go/auth" |
|
400 |
+ |
"github.com/stevedylandev/andromeda/crates-go/web" |
|
401 |
+ |
) |
| 333 |
402 |
|
|
| 334 |
|
- |
fn is_valid_session(state: &AppState, token: &str) -> bool { |
| 335 |
|
- |
// Check DB for token expiry — return true if token exists and hasn't expired |
| 336 |
|
- |
match crate::db::get_session_expiry(&state.db, token) { |
| 337 |
|
- |
Ok(Some(expires_at)) => expires_at > chrono::Utc::now().to_rfc3339(), |
| 338 |
|
- |
_ => false, |
| 339 |
|
- |
} |
|
403 |
+ |
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) { |
|
404 |
+ |
web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log) |
| 340 |
405 |
|
} |
| 341 |
|
- |
``` |
| 342 |
406 |
|
|
| 343 |
|
- |
**Usage in handlers** — use `andromeda_auth` functions directly for login/logout: |
|
407 |
+ |
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) { |
|
408 |
+ |
if err := r.ParseForm(); err != nil { |
|
409 |
+ |
web.RedirectWithError(w, r, "/login", "Invalid request") |
|
410 |
+ |
return |
|
411 |
+ |
} |
|
412 |
+ |
if !auth.SecureEqual(r.FormValue("password"), a.Password) { |
|
413 |
+ |
web.RedirectWithError(w, r, "/login", "Invalid password") |
|
414 |
+ |
return |
|
415 |
+ |
} |
|
416 |
+ |
token, err := a.Sessions.Create() |
|
417 |
+ |
if err != nil { |
|
418 |
+ |
a.Log.Error("create session failed", "err", err) |
|
419 |
+ |
web.RedirectWithError(w, r, "/login", "Server error") |
|
420 |
+ |
return |
|
421 |
+ |
} |
|
422 |
+ |
http.SetCookie(w, a.Sessions.SessionCookie(token)) |
|
423 |
+ |
http.Redirect(w, r, "/", http.StatusSeeOther) |
|
424 |
+ |
} |
| 344 |
425 |
|
|
| 345 |
|
- |
```rust |
| 346 |
|
- |
use andromeda_auth; |
| 347 |
|
- |
|
| 348 |
|
- |
// POST /login — verify password, create session, set cookie |
| 349 |
|
- |
async fn post_login(State(state): State<Arc<AppState>>, Form(form): Form<LoginForm>) -> Response { |
| 350 |
|
- |
if andromeda_auth::verify_password(&form.password, &state.app_password) { |
| 351 |
|
- |
let token = andromeda_auth::generate_session_token(); |
| 352 |
|
- |
// Store token in DB with expiry... |
| 353 |
|
- |
let cookie = andromeda_auth::build_session_cookie(&token, state.cookie_secure); |
| 354 |
|
- |
// Set cookie header and redirect to / |
| 355 |
|
- |
} else { |
| 356 |
|
- |
// Redirect to /login?error=Invalid+password |
| 357 |
|
- |
} |
|
426 |
+ |
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) { |
|
427 |
+ |
if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" { |
|
428 |
+ |
a.Sessions.Delete(c.Value) |
|
429 |
+ |
} |
|
430 |
+ |
http.SetCookie(w, a.Sessions.ClearCookie()) |
|
431 |
+ |
http.Redirect(w, r, "/login", http.StatusSeeOther) |
| 358 |
432 |
|
} |
| 359 |
433 |
|
|
| 360 |
|
- |
// GET /logout — clear session |
| 361 |
|
- |
async fn get_logout(headers: HeaderMap, State(state): State<Arc<AppState>>) -> Response { |
| 362 |
|
- |
if let Some(token) = andromeda_auth::extract_session_cookie(&headers) { |
| 363 |
|
- |
let _ = crate::db::delete_session(&state.db, &token); |
| 364 |
|
- |
} |
| 365 |
|
- |
let cookie = andromeda_auth::clear_session_cookie(); |
| 366 |
|
- |
// Set cookie header and redirect to /login |
|
434 |
+ |
func (a *App) createItemHandler(w http.ResponseWriter, r *http.Request) { |
|
435 |
+ |
if err := r.ParseForm(); err != nil { |
|
436 |
+ |
web.RedirectWithError(w, r, "/items/new", "Invalid request") |
|
437 |
+ |
return |
|
438 |
+ |
} |
|
439 |
+ |
title := strings.TrimSpace(r.FormValue("title")) |
|
440 |
+ |
if title == "" { |
|
441 |
+ |
web.RedirectWithError(w, r, "/items/new", "Title is required") |
|
442 |
+ |
return |
|
443 |
+ |
} |
|
444 |
+ |
it, err := createItem(a.DB, title, r.FormValue("content")) |
|
445 |
+ |
if err != nil { |
|
446 |
+ |
a.Log.Error("create item failed", "err", err) |
|
447 |
+ |
web.RedirectWithError(w, r, "/items/new", "Failed to create item") |
|
448 |
+ |
return |
|
449 |
+ |
} |
|
450 |
+ |
http.Redirect(w, r, "/items/"+it.ShortID, http.StatusSeeOther) |
| 367 |
451 |
|
} |
| 368 |
452 |
|
``` |
| 369 |
453 |
|
|
| 370 |
|
- |
**Protect routes** — add `_session: auth::AuthSession` as a parameter: |
|
454 |
+ |
Patterns: |
|
455 |
+ |
- Always `auth.SecureEqual` for password compare (constant-time). |
|
456 |
+ |
- Use `http.StatusSeeOther` (303) for POST-redirect-GET. |
|
457 |
+ |
- Pre-rendered HTML (e.g. markdown) goes through `template.HTML` in the |
|
458 |
+ |
page-data struct, not via `|safe`-style filters. |
|
459 |
+ |
|
|
460 |
+ |
## handlers_api.go |
|
461 |
+ |
|
|
462 |
+ |
JSON API handlers. Use `web.WriteJSON`, `web.WriteError`, `web.DecodeJSON`. |
|
463 |
+ |
|
|
464 |
+ |
```go |
|
465 |
+ |
package main |
|
466 |
+ |
|
|
467 |
+ |
import ( |
|
468 |
+ |
"net/http" |
|
469 |
+ |
|
|
470 |
+ |
"github.com/stevedylandev/andromeda/crates-go/web" |
|
471 |
+ |
) |
| 371 |
472 |
|
|
| 372 |
|
- |
```rust |
| 373 |
|
- |
async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response { |
| 374 |
|
- |
// only reachable if session is valid |
|
473 |
+ |
func (a *App) apiListItems(w http.ResponseWriter, r *http.Request) { |
|
474 |
+ |
items, err := listItems(a.DB) |
|
475 |
+ |
if err != nil { |
|
476 |
+ |
a.Log.Error("list items failed", "err", err) |
|
477 |
+ |
web.WriteError(w, http.StatusInternalServerError, "internal error") |
|
478 |
+ |
return |
|
479 |
+ |
} |
|
480 |
+ |
web.WriteJSON(w, http.StatusOK, items) |
| 375 |
481 |
|
} |
| 376 |
|
- |
``` |
| 377 |
482 |
|
|
| 378 |
|
- |
**AppState for session auth:** |
|
483 |
+ |
func (a *App) apiCreateItem(w http.ResponseWriter, r *http.Request) { |
|
484 |
+ |
var in ItemInput |
|
485 |
+ |
if !web.DecodeJSON(w, r, &in) { |
|
486 |
+ |
return |
|
487 |
+ |
} |
|
488 |
+ |
if in.Title == "" { |
|
489 |
+ |
web.WriteError(w, http.StatusBadRequest, "title is required") |
|
490 |
+ |
return |
|
491 |
+ |
} |
|
492 |
+ |
it, err := createItem(a.DB, in.Title, in.Content) |
|
493 |
+ |
if err != nil { |
|
494 |
+ |
a.Log.Error("create item failed", "err", err) |
|
495 |
+ |
web.WriteError(w, http.StatusInternalServerError, "internal error") |
|
496 |
+ |
return |
|
497 |
+ |
} |
|
498 |
+ |
web.WriteJSON(w, http.StatusCreated, it) |
|
499 |
+ |
} |
| 379 |
500 |
|
|
| 380 |
|
- |
```rust |
| 381 |
|
- |
pub struct AppState { |
| 382 |
|
- |
pub db: Db, |
| 383 |
|
- |
pub app_password: String, |
| 384 |
|
- |
pub cookie_secure: bool, |
|
501 |
+ |
func (a *App) apiGetItem(w http.ResponseWriter, r *http.Request) { |
|
502 |
+ |
it, err := getItemByShortID(a.DB, r.PathValue("short_id")) |
|
503 |
+ |
if err != nil { |
|
504 |
+ |
web.WriteError(w, http.StatusInternalServerError, "internal error") |
|
505 |
+ |
return |
|
506 |
+ |
} |
|
507 |
+ |
if it == nil { |
|
508 |
+ |
web.WriteError(w, http.StatusNotFound, "not found") |
|
509 |
+ |
return |
|
510 |
+ |
} |
|
511 |
+ |
web.WriteJSON(w, http.StatusOK, it) |
| 385 |
512 |
|
} |
| 386 |
513 |
|
``` |
| 387 |
514 |
|
|
| 388 |
|
- |
### API key auth (alternative — for API-only apps) |
|
515 |
+ |
## Authentication |
|
516 |
+ |
|
|
517 |
+ |
`crates-go/auth` provides three middleware patterns. Pick based on app shape: |
|
518 |
+ |
|
|
519 |
+ |
### Session/cookie auth (web-facing apps) |
| 389 |
520 |
|
|
| 390 |
|
- |
For apps that don't need a login page (e.g., sipp), use API key middleware instead of session auth. This pattern does NOT use `andromeda-auth` — it's self-contained in `server.rs`: |
|
521 |
+ |
Use `*auth.Store` for password-login web apps (jotts, feeds, bookmarks, etc). |
|
522 |
+ |
Sessions stored in the same SQLite DB via `EnsureSchema()`. Routes guarded |
|
523 |
+ |
with `store.RequireSession("/login", handler)`. |
| 391 |
524 |
|
|
| 392 |
|
- |
```rust |
| 393 |
|
- |
#[derive(Clone)] |
| 394 |
|
- |
struct ServerConfig { |
| 395 |
|
- |
api_key: Option<String>, |
| 396 |
|
- |
auth_endpoints: HashSet<String>, |
| 397 |
|
- |
} |
| 398 |
|
- |
``` |
|
525 |
+ |
Key API: |
|
526 |
+ |
- `store.Create() (token, err)` — issue new session row. |
|
527 |
+ |
- `store.SessionCookie(token) *http.Cookie` — `HttpOnly`, `SameSite=Strict`, |
|
528 |
+ |
7-day MaxAge. |
|
529 |
+ |
- `store.ClearCookie()` — `MaxAge=-1` cookie for logout. |
|
530 |
+ |
- `store.HasValid(r)` — check the incoming request's cookie. |
|
531 |
+ |
- `store.PruneExpired()` — call at boot to GC stale rows. |
|
532 |
+ |
|
|
533 |
+ |
### API key auth (header-based) |
|
534 |
+ |
|
|
535 |
+ |
`auth.RequireAPIKey(expectedKey, next)`. Reads `X-API-Key` header, |
|
536 |
+ |
`SecureEqual` compare. Returns 403 if `expectedKey` is empty (auth disabled by |
|
537 |
+ |
config), 401 on mismatch. Use this for `/api/*` routes when there's also a |
|
538 |
+ |
session-cookie web UI. |
| 399 |
539 |
|
|
| 400 |
|
- |
See `apps/sipp` for the full API key middleware pattern. |
|
540 |
+ |
### Bearer-or-Session (mixed surfaces) |
| 401 |
541 |
|
|
| 402 |
|
- |
### Environment variables |
|
542 |
+ |
`auth.RequireBearerOrSession(store, expectedBearer, next)` — accepts either |
|
543 |
+ |
`Authorization: Bearer <token>` or a valid session cookie. Used by apps that |
|
544 |
+ |
expose the same endpoint to a CLI client AND a logged-in browser user (e.g. |
|
545 |
+ |
`sipp`, `jotts upload`). |
| 403 |
546 |
|
|
| 404 |
|
- |
Prefix app-specific env vars with the app name (e.g., `JOTTS_`, `SIPP_`). Shared vars like `HOST`, `PORT`, `COOKIE_SECURE` don't need a prefix: |
|
547 |
+ |
### Env vars |
| 405 |
548 |
|
|
| 406 |
549 |
|
| Variable | Purpose | Default | |
| 407 |
550 |
|
|----------|---------|---------| |
| 408 |
|
- |
| `HOST` | Bind address | `127.0.0.1` (set to `0.0.0.0` in Docker) | |
| 409 |
|
- |
| `PORT` | Listen port | `3000` | |
| 410 |
|
- |
| `APP_DB_PATH` | SQLite file path | `app.sqlite` | |
| 411 |
|
- |
| `APP_PASSWORD` | Single password for session auth (web apps) | None | |
| 412 |
|
- |
| `COOKIE_SECURE` | Set `true` for HTTPS-only cookies | `false` | |
| 413 |
|
- |
| `APP_API_KEY` | API key for API-key auth pattern | None (auth disabled) | |
| 414 |
|
- |
| `APP_AUTH_ENDPOINTS` | Comma-separated endpoint names, "all", or "none" | `api_delete,api_list,api_update` | |
|
551 |
+ |
| `HOST` | bind address | `127.0.0.1` (set `0.0.0.0` in Docker) | |
|
552 |
+ |
| `PORT` | listen port | `3000` | |
|
553 |
+ |
| `<APP>_DB_PATH` | SQLite path | `<app>.sqlite` | |
|
554 |
+ |
| `<APP>_PASSWORD` | session login password | none → "changeme" warning | |
|
555 |
+ |
| `<APP>_API_KEY` | API key for `/api/*` | none → 403 | |
|
556 |
+ |
| `COOKIE_SECURE` | HTTPS-only cookie flag | `false` | |
| 415 |
557 |
|
|
| 416 |
|
- |
## Templates (Askama) |
| 417 |
|
- |
|
| 418 |
|
- |
HTML templates live in `templates/` and use Askama syntax. Key patterns: |
|
558 |
+ |
App-specific env vars are prefixed with the uppercase app name |
|
559 |
+ |
(`JOTTS_PASSWORD`, `FEEDS_API_KEY`). `HOST`, `PORT`, `COOKIE_SECURE` are not |
|
560 |
+ |
prefixed. |
| 419 |
561 |
|
|
| 420 |
|
- |
- Link CSS via `/static/styles.css` |
| 421 |
|
- |
- Link assets via `/assets/filename` |
| 422 |
|
- |
- Include `<meta name="theme-color" content="#121113" />` |
| 423 |
|
- |
- Forms POST to web routes (not API routes) |
| 424 |
|
- |
- Use `{{ variable }}` for template interpolation |
| 425 |
|
- |
- Use `{{ variable|safe }}` for pre-rendered HTML (e.g., syntax highlighted content) |
|
562 |
+ |
## Templates (html/template) |
| 426 |
563 |
|
|
| 427 |
|
- |
### Template inheritance |
|
564 |
+ |
Templates live in `templates/`, parsed once at startup via |
|
565 |
+ |
`template.ParseFS(appFS, "templates/*.html")` and rendered with |
|
566 |
+ |
`web.Render(t, w, "name.html", data, log)`. |
| 428 |
567 |
|
|
| 429 |
|
- |
Use a `base.html` with block sections. All pages extend it: |
|
568 |
+ |
Use `{{ define "..." }}` / `{{ template "..." . }}` for layout composition. |
|
569 |
+ |
The base file defines block names; pages define the content blocks. |
| 430 |
570 |
|
|
| 431 |
571 |
|
**templates/base.html:** |
|
572 |
+ |
|
| 432 |
573 |
|
```html |
|
574 |
+ |
{{ define "base" }} |
| 433 |
575 |
|
<!DOCTYPE html> |
| 434 |
576 |
|
<html lang="en"> |
| 435 |
577 |
|
<head> |
| 436 |
578 |
|
<meta charset="UTF-8"> |
| 437 |
579 |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 438 |
|
- |
<title>{% block title %}APP_NAME{% endblock %}</title> |
|
580 |
+ |
<title>{{ template "title" . }}</title> |
| 439 |
581 |
|
<meta name="theme-color" content="#121113" /> |
| 440 |
|
- |
<style> |
| 441 |
|
- |
/* base styles here */ |
| 442 |
|
- |
</style> |
|
582 |
+ |
<link rel="stylesheet" href="/assets/darkmatter.css"> |
|
583 |
+ |
<link rel="stylesheet" href="/static/styles.css"> |
| 443 |
584 |
|
</head> |
| 444 |
585 |
|
<body> |
| 445 |
586 |
|
<div class="container"> |
| 446 |
|
- |
{% block content %}{% endblock %} |
|
587 |
+ |
{{ template "content" . }} |
| 447 |
588 |
|
</div> |
| 448 |
589 |
|
</body> |
| 449 |
590 |
|
</html> |
|
591 |
+ |
{{ end }} |
| 450 |
592 |
|
``` |
| 451 |
593 |
|
|
| 452 |
594 |
|
**templates/index.html:** |
| 453 |
|
- |
```html |
| 454 |
|
- |
{% extends "base.html" %} |
| 455 |
|
- |
{% block title %}Items{% endblock %} |
| 456 |
|
- |
{% block content %} |
| 457 |
|
- |
{% if let Some(error) = error %} |
| 458 |
|
- |
<p class="error">{{ error }}</p> |
| 459 |
|
- |
{% endif %} |
| 460 |
|
- |
{% for item in items %} |
| 461 |
|
- |
<div>{{ item.name }}</div> |
| 462 |
|
- |
{% endfor %} |
| 463 |
|
- |
{% endblock %} |
| 464 |
|
- |
``` |
| 465 |
595 |
|
|
| 466 |
|
- |
### Flash messages via query params |
| 467 |
|
- |
|
| 468 |
|
- |
Pass transient error/success messages through redirects using query parameters. No session flash needed. |
| 469 |
|
- |
|
| 470 |
|
- |
**Query param struct:** |
| 471 |
|
- |
```rust |
| 472 |
|
- |
#[derive(Deserialize, Default)] |
| 473 |
|
- |
pub struct FlashQuery { |
| 474 |
|
- |
pub error: Option<String>, |
| 475 |
|
- |
} |
| 476 |
|
- |
``` |
| 477 |
|
- |
|
| 478 |
|
- |
**In handlers** — redirect with message: |
| 479 |
|
- |
```rust |
| 480 |
|
- |
Redirect::to("/items/add?error=Name+is+required.").into_response() |
| 481 |
|
- |
``` |
| 482 |
|
- |
|
| 483 |
|
- |
**In receiving handler** — extract and pass to template: |
| 484 |
|
- |
```rust |
| 485 |
|
- |
async fn get_add(Query(q): Query<FlashQuery>) -> Response { |
| 486 |
|
- |
render(AddTemplate { error: q.error }) |
| 487 |
|
- |
} |
| 488 |
|
- |
``` |
| 489 |
|
- |
|
| 490 |
|
- |
**In template** — conditionally render: |
| 491 |
596 |
|
```html |
| 492 |
|
- |
{% if let Some(error) = error %} |
| 493 |
|
- |
<p class="error">{{ error }}</p> |
| 494 |
|
- |
{% endif %} |
|
597 |
+ |
{{ define "title" }}Items{{ end }} |
|
598 |
+ |
{{ define "content" }} |
|
599 |
+ |
{{ range .Items }} |
|
600 |
+ |
<a href="/items/{{ .ShortID }}">{{ .Title }}</a> |
|
601 |
+ |
{{ end }} |
|
602 |
+ |
{{ end }} |
|
603 |
+ |
{{ template "base" . }} |
| 495 |
604 |
|
``` |
| 496 |
605 |
|
|
| 497 |
|
- |
## Logging (tracing) |
| 498 |
|
- |
|
| 499 |
|
- |
Always initialize tracing in `main()` before anything else: |
| 500 |
|
- |
|
| 501 |
|
- |
```rust |
| 502 |
|
- |
tracing_subscriber::fmt::init(); |
| 503 |
|
- |
``` |
|
606 |
+ |
Notes: |
|
607 |
+ |
- Link CSS via `/static/styles.css` (app-local) and `/assets/darkmatter.css` |
|
608 |
+ |
(shared theme served by `darkmatter.Mount`). |
|
609 |
+ |
- Forms POST to web routes, not `/api/*`. |
|
610 |
+ |
- Pass pre-rendered HTML as `template.HTML(...)`; `html/template` auto-escapes |
|
611 |
+ |
everything else. |
|
612 |
+ |
- Flash errors via `?error=...` query param + `web.RedirectWithError` / |
|
613 |
+ |
`RedirectWithSuccess`. |
| 504 |
614 |
|
|
| 505 |
|
- |
Use throughout the app: |
| 506 |
|
- |
- `tracing::error!("DB error: {}", e)` — unrecoverable failures |
| 507 |
|
- |
- `tracing::warn!("Non-critical issue: {}", e)` — degraded but functional |
| 508 |
|
- |
- `tracing::info!("Listening on {}", addr)` — startup/lifecycle events |
|
615 |
+ |
## Static assets |
| 509 |
616 |
|
|
| 510 |
|
- |
## main.rs |
|
617 |
+ |
Embedded via `//go:embed templates/*.html static/*` on a single `appFS`. |
|
618 |
+ |
Serve under `/static/` via `web.EmbeddedHandler(appFS, "static")`. Shared |
|
619 |
+ |
theme files (CSS + fonts) come from `darkmatter.Mount(mux, "/assets")`. |
| 511 |
620 |
|
|
| 512 |
|
- |
Minimal — just loads env and starts the server: |
|
621 |
+ |
## Logging (slog) |
| 513 |
622 |
|
|
| 514 |
|
- |
```rust |
| 515 |
|
- |
mod auth; |
| 516 |
|
- |
mod db; |
| 517 |
|
- |
mod server; |
|
623 |
+ |
Standard library `log/slog`. Build once in `main`: |
| 518 |
624 |
|
|
| 519 |
|
- |
#[tokio::main] |
| 520 |
|
- |
async fn main() { |
| 521 |
|
- |
dotenvy::dotenv().ok(); |
| 522 |
|
- |
tracing_subscriber::fmt::init(); |
| 523 |
|
- |
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
| 524 |
|
- |
let port: u16 = std::env::var("PORT") |
| 525 |
|
- |
.ok() |
| 526 |
|
- |
.and_then(|v| v.parse().ok()) |
| 527 |
|
- |
.unwrap_or(3000); |
| 528 |
|
- |
server::run(host, port).await; |
| 529 |
|
- |
} |
|
625 |
+ |
```go |
|
626 |
+ |
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) |
| 530 |
627 |
|
``` |
| 531 |
628 |
|
|
| 532 |
|
- |
Keep main.rs minimal — all logic lives in `server.rs`, `db.rs`, and `auth.rs`. |
|
629 |
+ |
Pass it into `App` and pass it to `web.Render` (it logs template errors). |
|
630 |
+ |
Use `logger.Error("msg", "err", err)`, `logger.Warn(...)`, `logger.Info(...)`. |
| 533 |
631 |
|
|
| 534 |
632 |
|
## Dockerfile |
| 535 |
633 |
|
|
| 536 |
|
- |
Multi-stage workspace build using `cargo-chef` for dep caching. Built from repo root with `docker build -f apps/APP_NAME/Dockerfile .`: |
|
634 |
+ |
Multi-stage, built from the repo root so `crates-go/` is available for the |
|
635 |
+ |
`replace` directives. Pure Go (`CGO_ENABLED=0`) because the SQLite driver is |
|
636 |
+ |
`modernc.org/sqlite`. |
| 537 |
637 |
|
|
| 538 |
638 |
|
```dockerfile |
| 539 |
639 |
|
# Build from repo root: docker build -t APP_NAME -f apps/APP_NAME/Dockerfile . |
| 540 |
|
- |
FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
640 |
+ |
FROM golang:1.25-bookworm AS builder |
| 541 |
641 |
|
WORKDIR /app |
| 542 |
|
- |
|
| 543 |
|
- |
FROM chef AS planner |
| 544 |
|
- |
COPY . . |
| 545 |
|
- |
RUN cargo chef prepare --recipe-path recipe.json |
| 546 |
|
- |
|
| 547 |
|
- |
FROM chef AS builder |
| 548 |
|
- |
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
| 549 |
|
- |
COPY --from=planner /app/recipe.json recipe.json |
| 550 |
|
- |
RUN cargo chef cook --release --recipe-path recipe.json -p APP_NAME |
| 551 |
|
- |
COPY . . |
| 552 |
|
- |
RUN cargo build --release -p APP_NAME |
|
642 |
+ |
COPY crates-go/ ./crates-go/ |
|
643 |
+ |
COPY apps/APP_NAME/go.mod apps/APP_NAME/go.sum ./apps/APP_NAME/ |
|
644 |
+ |
WORKDIR /app/apps/APP_NAME |
|
645 |
+ |
RUN go mod download |
|
646 |
+ |
COPY apps/APP_NAME/ ./ |
|
647 |
+ |
RUN CGO_ENABLED=0 go build -o /APP_NAME . |
| 553 |
648 |
|
|
| 554 |
649 |
|
FROM debian:bookworm-slim |
| 555 |
|
- |
COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME |
|
650 |
+ |
COPY --from=builder /APP_NAME /usr/local/bin/APP_NAME |
| 556 |
651 |
|
WORKDIR /data |
| 557 |
|
- |
EXPOSE 3000 |
| 558 |
652 |
|
ENV HOST=0.0.0.0 |
| 559 |
653 |
|
ENV PORT=3000 |
|
654 |
+ |
EXPOSE 3000 |
| 560 |
655 |
|
CMD ["APP_NAME"] |
| 561 |
656 |
|
``` |
| 562 |
657 |
|
|
| 563 |
|
- |
Replace all `APP_NAME` with actual binary/package name. If app makes HTTPS requests (uses reqwest etc.), add `RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*` to final stage. |
|
658 |
+ |
If the app makes outbound HTTPS requests, add to the final stage: |
|
659 |
+ |
```dockerfile |
|
660 |
+ |
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* |
|
661 |
+ |
``` |
|
662 |
+ |
|
|
663 |
+ |
If the app has CLI subcommands (jotts), set `CMD ["APP_NAME", "server"]`. |
| 564 |
664 |
|
|
| 565 |
|
- |
## docker-compose.yml |
|
665 |
+ |
## App-local docker-compose.yml |
| 566 |
666 |
|
|
| 567 |
667 |
|
```yaml |
| 568 |
668 |
|
services: |
|
| 573 |
673 |
|
ports: |
| 574 |
674 |
|
- "${PORT:-3000}:${PORT:-3000}" |
| 575 |
675 |
|
environment: |
| 576 |
|
- |
- APP_PASSWORD=${APP_PASSWORD:-changeme} |
| 577 |
|
- |
- APP_DB_PATH=/data/APP_NAME.sqlite |
|
676 |
+ |
- APP_NAME_PASSWORD=${APP_NAME_PASSWORD:-changeme} |
|
677 |
+ |
- APP_NAME_DB_PATH=/data/APP_NAME.sqlite |
| 578 |
678 |
|
- COOKIE_SECURE=false |
| 579 |
679 |
|
- HOST=0.0.0.0 |
| 580 |
680 |
|
- PORT=${PORT:-3000} |
| 581 |
681 |
|
volumes: |
| 582 |
|
- |
- app-data:/data |
|
682 |
+ |
- APP_NAME-data:/data |
| 583 |
683 |
|
restart: unless-stopped |
| 584 |
684 |
|
|
| 585 |
685 |
|
volumes: |
| 586 |
|
- |
app-data: |
|
686 |
+ |
APP_NAME-data: |
| 587 |
687 |
|
``` |
| 588 |
688 |
|
|
| 589 |
|
- |
Key points: |
| 590 |
|
- |
- `context: ../..` builds from the workspace root |
| 591 |
|
- |
- Named volume persists the SQLite database across container restarts |
| 592 |
|
- |
- ENV vars use app-specific prefixes (e.g., `JOTTS_PASSWORD`, `SIPP_API_KEY`) |
| 593 |
|
- |
|
| 594 |
689 |
|
## .env.example |
| 595 |
690 |
|
|
| 596 |
|
- |
Always create one with all configurable env vars and sensible comments. |
|
691 |
+ |
Always ship one listing every var the app reads, with short comments. |
| 597 |
692 |
|
|
| 598 |
693 |
|
## Wiring up a new app |
| 599 |
694 |
|
|
| 600 |
|
- |
A new app is not done when `cargo run -p APP_NAME` works. Several workspace-level files list every app explicitly and must be updated, or CI/release/deploy break silently. |
|
695 |
+ |
A new app is not done when `go run .` works. Several workspace-level files |
|
696 |
+ |
list every app explicitly and must be updated, or CI / Docker images / deploy |
|
697 |
+ |
break silently. |
| 601 |
698 |
|
|
| 602 |
|
- |
### 1. Workspace root `Cargo.toml` |
|
699 |
+ |
### 1. Root `docker-compose.yml` (production) |
| 603 |
700 |
|
|
| 604 |
|
- |
Add to `[workspace] members`: |
|
701 |
+ |
Pulls images from GHCR. Add a service + named volume: |
| 605 |
702 |
|
|
| 606 |
|
- |
```toml |
| 607 |
|
- |
[workspace] |
| 608 |
|
- |
members = [ |
| 609 |
|
- |
"apps/sipp", |
| 610 |
|
- |
# ... |
| 611 |
|
- |
"apps/APP_NAME", |
| 612 |
|
- |
"crates/auth", |
| 613 |
|
- |
"crates/db", |
| 614 |
|
- |
"crates/darkmatter-css", |
| 615 |
|
- |
] |
|
703 |
+ |
```yaml |
|
704 |
+ |
services: |
|
705 |
+ |
APP_NAME: |
|
706 |
+ |
image: ghcr.io/stevedylandev/andromeda/APP_NAME:latest |
|
707 |
+ |
restart: unless-stopped |
|
708 |
+ |
ports: |
|
709 |
+ |
- "PORT:3000" |
|
710 |
+ |
volumes: |
|
711 |
+ |
- APP_NAME_data:/data |
|
712 |
+ |
env_file: apps/APP_NAME/.env |
| 616 |
713 |
|
``` |
| 617 |
714 |
|
|
| 618 |
|
- |
### 2. `dist-workspace.toml` |
| 619 |
|
- |
|
| 620 |
|
- |
Add to `[workspace] members` so `cargo dist` builds release binaries + Homebrew formula: |
|
715 |
+ |
Pick a unique host port (existing: 3000 sipp, 3030 jotts, 3456 og, 3535 posts, |
|
716 |
+ |
3565 shrink, 4555 feeds, 4646 library, 6754 cellar). Drop the `volumes` / |
|
717 |
+ |
`env_file` lines if stateless (see `shrink`, `og`). |
| 621 |
718 |
|
|
| 622 |
|
- |
```toml |
| 623 |
|
- |
[workspace] |
| 624 |
|
- |
members = [ |
| 625 |
|
- |
"cargo:apps/sipp", |
| 626 |
|
- |
# ... |
| 627 |
|
- |
"cargo:apps/APP_NAME", |
| 628 |
|
- |
] |
| 629 |
|
- |
``` |
|
719 |
+ |
If the app holds data worth backing up, mount its volume read-only into the |
|
720 |
+ |
`backup` service. |
| 630 |
721 |
|
|
| 631 |
|
- |
### 3. `.github/workflows/docker.yml` |
|
722 |
+ |
### 2. `.github/workflows/docker.yml` |
| 632 |
723 |
|
|
| 633 |
724 |
|
Two spots — both list every app: |
| 634 |
725 |
|
|
| 635 |
726 |
|
```yaml |
| 636 |
|
- |
ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","APP_NAME"]' |
|
727 |
+ |
ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp","APP_NAME"]' |
| 637 |
728 |
|
``` |
| 638 |
729 |
|
|
| 639 |
730 |
|
```yaml |
| 640 |
|
- |
for app in cellar sipp feeds parcels jotts og shrink backup posts library APP_NAME; do |
|
731 |
+ |
for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp APP_NAME; do |
| 641 |
732 |
|
``` |
| 642 |
733 |
|
|
| 643 |
|
- |
If the cargo package name differs from directory name, add a case to `pkg_to_dir()`: |
|
734 |
+ |
### 3. `.github/workflows/docker-test.yml` |
| 644 |
735 |
|
|
| 645 |
|
- |
```bash |
| 646 |
|
- |
pkg_to_dir() { |
| 647 |
|
- |
case "$1" in |
| 648 |
|
- |
sipp-so) echo "sipp" ;; |
| 649 |
|
- |
APP_PKG) echo "APP_NAME" ;; |
| 650 |
|
- |
*) echo "$1" ;; |
| 651 |
|
- |
esac |
| 652 |
|
- |
} |
| 653 |
|
- |
``` |
|
736 |
+ |
Same two lists — keep in lockstep with `docker.yml`. |
| 654 |
737 |
|
|
| 655 |
|
- |
### 4. `.github/workflows/docker-test.yml` |
|
738 |
+ |
### 4. App-local `docker-compose.yml` |
| 656 |
739 |
|
|
| 657 |
|
- |
Same two lists — keep in sync with `docker.yml`: |
|
740 |
+ |
Per-app dev compose (see above). |
| 658 |
741 |
|
|
| 659 |
|
- |
```yaml |
| 660 |
|
- |
ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","APP_NAME"]' |
| 661 |
|
- |
``` |
|
742 |
+ |
## Useful commands |
| 662 |
743 |
|
|
| 663 |
|
- |
```yaml |
| 664 |
|
- |
for app in cellar sipp feeds parcels jotts og shrink backup posts library APP_NAME; do |
| 665 |
|
- |
``` |
|
744 |
+ |
Per-app (from `apps/<app>/`): |
| 666 |
745 |
|
|
| 667 |
|
- |
### 5. Root `docker-compose.yml` |
| 668 |
|
- |
|
| 669 |
|
- |
Production compose at repo root pulls images from GHCR. Add a service + volume: |
| 670 |
|
- |
|
| 671 |
|
- |
```yaml |
| 672 |
|
- |
services: |
| 673 |
|
- |
APP_NAME: |
| 674 |
|
- |
image: ghcr.io/stevedylandev/andromeda/APP_NAME:latest |
| 675 |
|
- |
restart: unless-stopped |
| 676 |
|
- |
ports: |
| 677 |
|
- |
- "PORT:3000" |
| 678 |
|
- |
volumes: |
| 679 |
|
- |
- APP_NAME_data:/data |
| 680 |
|
- |
env_file: apps/APP_NAME/.env |
| 681 |
|
- |
|
| 682 |
|
- |
volumes: |
| 683 |
|
- |
APP_NAME_data: |
| 684 |
|
- |
external: true |
| 685 |
|
- |
name: APP_NAME_APP_NAME-data |
|
746 |
+ |
```bash |
|
747 |
+ |
go mod tidy |
|
748 |
+ |
go build ./... |
|
749 |
+ |
go vet ./... |
|
750 |
+ |
go test ./... |
|
751 |
+ |
go run . # or: go run . server for apps with subcommands |
| 686 |
752 |
|
``` |
| 687 |
753 |
|
|
| 688 |
|
- |
Pick a unique host port (existing: 3000 sipp, 3030 jotts, 3456 og, 3535 posts, 3565 shrink, 4555 feeds, 4646 library, 6754 cellar). Drop `volumes`/`env_file` if app is stateless (see `shrink`, `og`). |
| 689 |
|
- |
|
| 690 |
|
- |
If the app holds data worth backing up, add a read-only mount to the `backup` service: |
|
754 |
+ |
Repo-wide (from root): |
| 691 |
755 |
|
|
| 692 |
|
- |
```yaml |
| 693 |
|
- |
backup: |
| 694 |
|
- |
volumes: |
| 695 |
|
- |
- APP_NAME_data:/data/APP_NAME:ro |
|
756 |
+ |
```bash |
|
757 |
+ |
make go-check # fmt + test + vet across all Go modules |
|
758 |
+ |
make go-test |
|
759 |
+ |
make go-vet |
|
760 |
+ |
make go-fmt |
|
761 |
+ |
make go-app-test APP=APP_NAME |
|
762 |
+ |
make go-app-vet APP=APP_NAME |
|
763 |
+ |
make go-app-fmt APP=APP_NAME |
| 696 |
764 |
|
``` |
| 697 |
765 |
|
|
| 698 |
|
- |
### 6. App-local `docker-compose.yml` |
| 699 |
|
- |
|
| 700 |
|
- |
Per-app compose for local dev — builds from source: |
| 701 |
|
- |
|
| 702 |
|
- |
```yaml |
| 703 |
|
- |
services: |
| 704 |
|
- |
app: |
| 705 |
|
- |
build: |
| 706 |
|
- |
context: ../.. |
| 707 |
|
- |
dockerfile: apps/APP_NAME/Dockerfile |
| 708 |
|
- |
ports: |
| 709 |
|
- |
- "${PORT:-3000}:${PORT:-3000}" |
| 710 |
|
- |
environment: |
| 711 |
|
- |
- APP_PASSWORD=${APP_PASSWORD:-changeme} |
| 712 |
|
- |
- APP_DB_PATH=/data/APP_NAME.sqlite |
| 713 |
|
- |
- COOKIE_SECURE=false |
| 714 |
|
- |
- HOST=0.0.0.0 |
| 715 |
|
- |
- PORT=${PORT:-3000} |
| 716 |
|
- |
volumes: |
| 717 |
|
- |
- APP_NAME-data:/data |
| 718 |
|
- |
restart: unless-stopped |
| 719 |
|
- |
|
| 720 |
|
- |
volumes: |
| 721 |
|
- |
APP_NAME-data: |
| 722 |
|
- |
``` |
|
766 |
+ |
The shared crates (`crates-go/web`, `crates-go/auth`, `crates-go/config`, |
|
767 |
+ |
`crates-go/sqlite`, `crates-go/darkmatter`) are each their own module — run |
|
768 |
+ |
`go build ./...` inside any to check in isolation. |
| 723 |
769 |
|
|
| 724 |
770 |
|
## Checklist |
| 725 |
771 |
|
|
| 726 |
772 |
|
When scaffolding a new app: |
| 727 |
773 |
|
|
| 728 |
|
- |
1. Create `apps/APP_NAME/` with `cargo init` |
| 729 |
|
- |
2. Set up `apps/APP_NAME/Cargo.toml` with workspace deps + `andromeda-auth` (and `andromeda-db`, `andromeda-darkmatter-css` if used) |
| 730 |
|
- |
3. Register app in workspace root `Cargo.toml` under `[workspace] members` |
| 731 |
|
- |
4. Register app in `dist-workspace.toml` under `[workspace] members` (with `cargo:` prefix) |
| 732 |
|
- |
5. Add app to `ALL` array + `for app in ...` loop in `.github/workflows/docker.yml` |
| 733 |
|
- |
6. Add app to `ALL` array + `for app in ...` loop in `.github/workflows/docker-test.yml` |
| 734 |
|
- |
7. Write `db.rs` — schema, model, CRUD, session table if needed |
| 735 |
|
- |
8. Write `auth.rs` — wrap `andromeda-auth` with `AuthSession` extractor (if auth needed) |
| 736 |
|
- |
9. Write `server.rs` — config, state, templates, handlers, routes |
| 737 |
|
- |
10. Write `main.rs` — minimal entry point |
| 738 |
|
- |
11. Create `templates/` with `base.html` + index page |
| 739 |
|
- |
12. Create `static/styles.css` |
| 740 |
|
- |
13. Create `.env.example` |
| 741 |
|
- |
14. Create `Dockerfile` (cargo-chef multi-stage) + app-local `docker-compose.yml` |
| 742 |
|
- |
15. Add service entry + volume to root `docker-compose.yml` (and `backup` mount if stateful) |
| 743 |
|
- |
16. Test: `cargo run -p APP_NAME`, hit routes |
| 744 |
|
- |
17. Test: `docker build -f apps/APP_NAME/Dockerfile .` from repo root |
|
774 |
+ |
1. `mkdir apps/APP_NAME && cd apps/APP_NAME && go mod init github.com/stevedylandev/andromeda/apps/APP_NAME` |
|
775 |
+ |
2. Add shared-crate `require` + `replace` directives to `go.mod` (auth, config, sqlite, web, darkmatter). |
|
776 |
+ |
3. `main.go` — env load, DB open, sessions bootstrap, `App` build, `ListenAndServe`. |
|
777 |
+ |
4. `app.go` — `App` struct, `//go:embed`, page-data structs. |
|
778 |
+ |
5. `routes.go` — mux wiring, middleware, `/static/` + `darkmatter.Mount`. |
|
779 |
+ |
6. `db.go` — schema, model, CRUD via `crates-go/sqlite.Open`. |
|
780 |
+ |
7. `handlers_web.go` — login/logout + HTML CRUD pages. |
|
781 |
+ |
8. `handlers_api.go` — JSON CRUD endpoints behind `RequireAPIKey`. |
|
782 |
+ |
9. `templates/base.html` + per-page templates using `{{ define }}` blocks. |
|
783 |
+ |
10. `static/styles.css` + any app-specific assets. |
|
784 |
+ |
11. `.env.example` listing every env var. |
|
785 |
+ |
12. `Dockerfile` (multi-stage, `CGO_ENABLED=0`) + app-local `docker-compose.yml`. |
|
786 |
+ |
13. Add service + volume to root `docker-compose.yml` (+ `backup` mount if stateful). |
|
787 |
+ |
14. Add app name to both `ALL` array + `for app in ...` loops in |
|
788 |
+ |
`.github/workflows/docker.yml` AND `.github/workflows/docker-test.yml`. |
|
789 |
+ |
15. `go mod tidy`, then `make go-app-vet APP=APP_NAME` and `make go-app-test APP=APP_NAME`. |
|
790 |
+ |
16. `docker build -f apps/APP_NAME/Dockerfile .` from repo root to verify image. |
| 745 |
791 |
|
|
| 746 |
792 |
|
## What NOT to include |
| 747 |
793 |
|
|
| 748 |
|
- |
- No external CSS frameworks unless specified |
| 749 |
|
- |
- No ORMs — use raw rusqlite |
| 750 |
|
- |
- No connection pools — `Arc<Mutex<Connection>>` is sufficient for SQLite |
| 751 |
|
- |
- No async database drivers — rusqlite is synchronous and that's fine |
|
794 |
+ |
- No web frameworks (gin, echo, chi, fiber) — stdlib `net/http` mux is enough. |
|
795 |
+ |
- No ORMs — raw `database/sql` + `?` placeholders. |
|
796 |
+ |
- No connection pools — `sqlite.Open` sets `MaxOpenConns(1)` on purpose. |
|
797 |
+ |
- No cgo SQLite drivers — use `modernc.org/sqlite` via `crates-go/sqlite`. |
|
798 |
+ |
- No external CSS frameworks unless specified — use `crates-go/darkmatter`. |
|
799 |
+ |
- No JS frontend / build step — `html/template` rendered server-side. |
|
800 |
+ |
- No CLI / TUI deps (cobra, bubbletea) unless the app actually needs them. |
|
801 |
+ |
- No `replace` directives for anything outside `crates-go/`. |