feat: add post endpoint to, well, posts
e1921b14
8 file(s) · +131 −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 |
| 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`. |
| 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 { |
| 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} |
| 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 | + | } |
|
| 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") |
| 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) |
|
| 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 | ||