feat: add post endpoint to, well, posts e1921b14
Steve · 2026-10-05 23:35 8 file(s) · +131 −0
apps/posts/.env.example +1 −0
1 1
POSTS_PASSWORD=changeme
2 +
POSTS_API_KEY=
2 3
POSTS_DB_PATH=posts.sqlite
3 4
UPLOADS_DIR=uploads
4 5
COOKIE_SECURE=false
apps/posts/README.md +5 −0
40 40
- Markdown: `github.com/yuin/goldmark` with GFM + Footnotes.
41 41
- Zip via stdlib `archive/zip`. Upload limit 10 MB; import zip limit 50 MB.
42 42
- API: `GET /api/posts` and `GET /api/posts/{slug}` (permissive CORS).
43 +
- `POST /api/posts` creates a post (requires `X-API-Key` header matching
44 +
  `POSTS_API_KEY`; disabled when unset). JSON body: `content` (required),
45 +
  optional `title`, `slug`, `status` (`draft`|`published`, default `draft`),
46 +
  `alias`, `canonical_url`, `published_date`, `meta_description`,
47 +
  `meta_image`, `lang`, `tags`, `weather`.
43 48
44 49
See `.env.example`.
apps/posts/app.go +1 −0
24 24
	UploadsDir   string
25 25
	Storage      poststorage.Backend
26 26
	SiteURL      string
27 +
	APIKey       string
27 28
}
28 29
29 30
type Post struct {
apps/posts/docker-compose.yml +1 −0
10 10
      - PORT=${PORT:-3000}
11 11
      - POSTS_DB_PATH=/data/posts-go.sqlite
12 12
      - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme}
13 +
      - POSTS_API_KEY=${POSTS_API_KEY:-}
13 14
      - UPLOADS_DIR=/data/uploads
14 15
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
15 16
      - SITE_URL=${SITE_URL:-http://localhost:3000}
apps/posts/handlers_api.go +79 −0
3 3
import (
4 4
	"net/http"
5 5
	"strconv"
6 +
	"strings"
6 7
7 8
	"github.com/stevedylandev/andromeda/pkg/web"
8 9
)
93 94
	}
94 95
	web.WriteJSON(w, http.StatusOK, toDetail(*post))
95 96
}
97 +
98 +
type apiCreatePostRequest struct {
99 +
	Title           string `json:"title"`
100 +
	Slug            string `json:"slug"`
101 +
	Content         string `json:"content"`
102 +
	Status          string `json:"status"`
103 +
	Alias           string `json:"alias"`
104 +
	CanonicalURL    string `json:"canonical_url"`
105 +
	PublishedDate   string `json:"published_date"`
106 +
	MetaDescription string `json:"meta_description"`
107 +
	MetaImage       string `json:"meta_image"`
108 +
	Lang            string `json:"lang"`
109 +
	Tags            string `json:"tags"`
110 +
	Weather         string `json:"weather"`
111 +
}
112 +
113 +
func (a *App) apiCreatePost(w http.ResponseWriter, r *http.Request) {
114 +
	var req apiCreatePostRequest
115 +
	if !web.DecodeJSON(w, r, &req) {
116 +
		return
117 +
	}
118 +
	if strings.TrimSpace(req.Content) == "" {
119 +
		web.WriteError(w, http.StatusBadRequest, "content is required")
120 +
		return
121 +
	}
122 +
	status := strings.TrimSpace(req.Status)
123 +
	if status == "" {
124 +
		status = "draft"
125 +
	}
126 +
	if status != "draft" && status != "published" {
127 +
		web.WriteError(w, http.StatusBadRequest, "status must be 'draft' or 'published'")
128 +
		return
129 +
	}
130 +
	title := strings.TrimSpace(req.Title)
131 +
	slug := deriveSlugWith(a, title, strings.TrimSpace(req.Slug))
132 +
	existing, err := getPostBySlug(a.DB, slug)
133 +
	if err != nil {
134 +
		web.WriteError(w, http.StatusInternalServerError, "internal server error")
135 +
		return
136 +
	}
137 +
	if existing != nil {
138 +
		web.WriteError(w, http.StatusConflict, "slug already exists")
139 +
		return
140 +
	}
141 +
	lang := "en"
142 +
	if l := strings.TrimSpace(req.Lang); l != "" {
143 +
		lang = l
144 +
	}
145 +
	weather := strings.TrimSpace(req.Weather)
146 +
	if weather == "" {
147 +
		defaultLocation, err := getSetting(a.DB, "default_location")
148 +
		if err != nil {
149 +
			defaultLocation = ""
150 +
		}
151 +
		weather = getWeather(defaultLocation)
152 +
	}
153 +
	pub := strings.TrimSpace(req.PublishedDate)
154 +
	if pub == "" {
155 +
		pub = nowDatetime()
156 +
	}
157 +
	in := PostInput{
158 +
		Title: optStr(title), Slug: slug, Content: req.Content,
159 +
		Status: status, Alias: optStr(req.Alias),
160 +
		CanonicalURL:    optStr(req.CanonicalURL),
161 +
		PublishedDate:   &pub,
162 +
		MetaDescription: optStr(req.MetaDescription),
163 +
		MetaImage:       optStr(req.MetaImage),
164 +
		Lang:            lang, Tags: optStr(req.Tags),
165 +
		Weather:         optStr(weather),
166 +
	}
167 +
	post, err := createPost(a.DB, in)
168 +
	if err != nil {
169 +
		a.Log.Error("api create post", "err", err)
170 +
		web.WriteError(w, http.StatusInternalServerError, "failed to create post")
171 +
		return
172 +
	}
173 +
	web.WriteJSON(w, http.StatusCreated, toDetail(*post))
174 +
}
apps/posts/main.go +1 −0
76 76
		UploadsDir:   uploadsDir,
77 77
		Storage:      storageBackend,
78 78
		SiteURL:      strings.TrimRight(config.Getenv("SITE_URL", "http://localhost:3000"), "/"),
79 +
		APIKey:       config.Getenv("POSTS_API_KEY", ""),
79 80
	}
80 81
81 82
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
apps/posts/routes.go +5 −0
4 4
	"net/http"
5 5
	"strings"
6 6
7 +
	"github.com/stevedylandev/andromeda/pkg/auth"
7 8
	"github.com/stevedylandev/andromeda/pkg/darkmatter"
8 9
	"github.com/stevedylandev/andromeda/pkg/web"
9 10
)
13 14
14 15
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
15 16
		return a.Sessions.RequireSession("/admin/login", next)
17 +
	}
18 +
	requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
19 +
		return auth.RequireAPIKey(a.APIKey, next)
16 20
	}
17 21
	cors := func(next http.HandlerFunc) http.HandlerFunc {
18 22
		return func(w http.ResponseWriter, r *http.Request) {
36 40
	// API
37 41
	mux.HandleFunc("GET /api/posts", cors(a.apiListPosts))
38 42
	mux.HandleFunc("GET /api/posts/{slug}", cors(a.apiGetPost))
43 +
	mux.HandleFunc("POST /api/posts", requireAPIKey(a.apiCreatePost))
39 44
40 45
	// Admin auth
41 46
	mux.HandleFunc("GET /admin/login", a.loginGet)
docs/docs/pages/apps/posts.mdx +38 −0
11 11
- File uploads with admin management
12 12
- Custom CSS support from the admin panel
13 13
- RSS feed at `/feed.xml`
14 +
- JSON API for reading and creating posts
14 15
- Dark themed UI with Commit Mono font
15 16
- SQLite for persistent storage
16 17
21 22
| Variable | Description | Default |
22 23
|---|---|---|
23 24
| `POSTS_PASSWORD` | Password for admin login | `changeme` |
25 +
| `POSTS_API_KEY` | API key for creating posts via `POST /api/posts` | _(unset)_ |
24 26
| `POSTS_DB_PATH` | SQLite database file path | `posts.sqlite` |
25 27
| `UPLOADS_DIR` | Directory for uploaded files (used when R2 unset) | `uploads` |
26 28
| `SITE_URL` | Public URL for RSS feed and links | `http://localhost:3000` |
34 36
| `R2_PUBLIC_URL` | Public URL prefix for uploaded files | _(unset)_ |
35 37
36 38
Upload storage falls back to local filesystem (`UPLOADS_DIR`) when `R2_BUCKET` is empty. Set all five `R2_*` variables to switch uploads to Cloudflare R2.
39 +
40 +
If `POSTS_API_KEY` is not set, `POST /api/posts` returns `403`. The read-only `GET` endpoints are always public.
37 41
38 42
## Deploy
39 43
75 79
## Use
76 80
77 81
Log in at `/login` with your configured password to access the admin panel. From there you can manage blog posts, static pages, file uploads, and site settings including custom CSS. Your blog's RSS feed is available at `/feed.xml`.
82 +
83 +
### API Endpoints
84 +
85 +
| Method | Path | Description |
86 +
|---|---|---|
87 +
| `GET` | `/api/posts?limit=30` | List published posts |
88 +
| `GET` | `/api/posts/{slug}` | Get a published post by slug |
89 +
| `POST` | `/api/posts` | Create a post |
90 +
91 +
`POST /api/posts` requires an `x-api-key` header matching `POSTS_API_KEY`. The JSON body accepts:
92 +
93 +
| Field | Description | Default |
94 +
|---|---|---|
95 +
| `content` | Markdown content (required) | — |
96 +
| `title` | Post title | _(none)_ |
97 +
| `slug` | URL slug | Derived from title, or a random ID |
98 +
| `status` | `draft` or `published` | `draft` |
99 +
| `published_date` | Publish date (RFC3339 or `YYYY-MM-DD`) | Now |
100 +
| `lang` | Language code | `en` |
101 +
| `tags` | Comma-separated tags | _(none)_ |
102 +
| `alias` | Alternate path that redirects to the post | _(none)_ |
103 +
| `canonical_url` | Canonical URL | _(none)_ |
104 +
| `meta_description` | Meta description | _(none)_ |
105 +
| `meta_image` | Meta image URL | _(none)_ |
106 +
| `weather` | Weather string | Fetched for the default location |
107 +
108 +
Returns `201` with the created post, `400` for invalid input, or `409` if the slug already exists.
109 +
110 +
```bash
111 +
curl -X POST https://your-posts-instance.com/api/posts \
112 +
  -H "x-api-key: $POSTS_API_KEY" \
113 +
  -H "Content-Type: application/json" \
114 +
  -d '{"title": "Hello World", "content": "My first post via the API", "status": "published"}'
115 +
```
78 116
79 117
## Acknowledgements
80 118