first commit
This commit is contained in:
115
internal/blog/post.go
Normal file
115
internal/blog/post.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package blog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// FrontMatter is the YAML block at the top of a post file.
|
||||
type FrontMatter struct {
|
||||
Title string `yaml:"title"`
|
||||
Date string `yaml:"date"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Slug string `yaml:"slug"`
|
||||
Draft bool `yaml:"draft"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
// Post is a parsed blog post with rendered HTML content.
|
||||
type Post struct {
|
||||
FrontMatter
|
||||
ParsedDate time.Time
|
||||
Content template.HTML
|
||||
Slug string
|
||||
}
|
||||
|
||||
// RenderMarkdown converts a markdown string to HTML.
|
||||
func RenderMarkdown(src string) (template.HTML, error) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.Footnote,
|
||||
extension.DefinitionList,
|
||||
extension.Strikethrough,
|
||||
extension.Table,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithFormatOptions(
|
||||
chromahtml.WithClasses(true),
|
||||
),
|
||||
),
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithXHTML(),
|
||||
),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(src), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
|
||||
// ParsePost parses raw markdown bytes (with optional YAML frontmatter) into a Post.
|
||||
// filename is the bare filename (e.g. "my-post.md") used to derive the slug if not set.
|
||||
func ParsePost(raw []byte, filename string) (*Post, error) {
|
||||
content := string(raw)
|
||||
var fm FrontMatter
|
||||
var markdownContent string
|
||||
|
||||
if strings.HasPrefix(content, "---") {
|
||||
// Find closing ---
|
||||
rest := content[3:]
|
||||
idx := strings.Index(rest, "\n---")
|
||||
if idx >= 0 {
|
||||
fmRaw := rest[:idx]
|
||||
if err := yaml.Unmarshal([]byte(fmRaw), &fm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markdownContent = strings.TrimSpace(rest[idx+4:])
|
||||
} else {
|
||||
markdownContent = content
|
||||
}
|
||||
} else {
|
||||
markdownContent = content
|
||||
}
|
||||
|
||||
htmlContent, err := RenderMarkdown(markdownContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slug := fm.Slug
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filename, ".md")
|
||||
}
|
||||
|
||||
var parsedDate time.Time
|
||||
if fm.Date != "" {
|
||||
for _, layout := range []string{"2006-01-02", "2006-01-02T15:04:05Z07:00"} {
|
||||
if t, err := time.Parse(layout, fm.Date); err == nil {
|
||||
parsedDate = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Post{
|
||||
FrontMatter: fm,
|
||||
ParsedDate: parsedDate,
|
||||
Content: htmlContent,
|
||||
Slug: slug,
|
||||
}, nil
|
||||
}
|
||||
182
internal/blog/store.go
Normal file
182
internal/blog/store.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package blog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Store manages blog posts stored as markdown files on disk.
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewStore creates a Store rooted at dir, creating the directory if needed.
|
||||
func NewStore(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
}
|
||||
|
||||
// All returns posts sorted by date descending.
|
||||
// If includeDrafts is false, draft posts are excluded.
|
||||
func (s *Store) All(includeDrafts bool) ([]*Post, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var posts []*Post
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
post, err := ParsePost(raw, e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if post.Draft && !includeDrafts {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
|
||||
sort.Slice(posts, func(i, j int) bool {
|
||||
return posts[i].ParsedDate.After(posts[j].ParsedDate)
|
||||
})
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// ByTag returns published posts matching a given tag.
|
||||
func (s *Store) ByTag(tag string) ([]*Post, error) {
|
||||
all, err := s.All(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []*Post
|
||||
for _, p := range all {
|
||||
for _, t := range p.Tags {
|
||||
if strings.EqualFold(t, tag) {
|
||||
filtered = append(filtered, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// Get returns a single post by slug.
|
||||
func (s *Store) Get(slug string) (*Post, error) {
|
||||
// Try filename = slug + ".md"
|
||||
raw, err := os.ReadFile(filepath.Join(s.dir, slug+".md"))
|
||||
if err == nil {
|
||||
return ParsePost(raw, slug+".md")
|
||||
}
|
||||
|
||||
// Fall back: scan all posts
|
||||
posts, err := s.All(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range posts {
|
||||
if p.Slug == slug {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("post not found: " + slug)
|
||||
}
|
||||
|
||||
// RawContent returns the raw markdown source for a post.
|
||||
func (s *Store) RawContent(slug string) (string, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(s.dir, slug+".md"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// Save writes raw markdown content to a file named slug+".md".
|
||||
func (s *Store) Save(slug, content string) error {
|
||||
return os.WriteFile(filepath.Join(s.dir, slug+".md"), []byte(content), 0644)
|
||||
}
|
||||
|
||||
// Delete removes the file for a given slug.
|
||||
func (s *Store) Delete(slug string) error {
|
||||
return os.Remove(filepath.Join(s.dir, slug+".md"))
|
||||
}
|
||||
|
||||
// Search returns published posts whose title, description, tags, or raw markdown
|
||||
// content contain the query string (case-insensitive).
|
||||
func (s *Store) Search(query string) ([]*Post, error) {
|
||||
posts, err := s.All(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
if q == "" {
|
||||
return posts, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var results []*Post
|
||||
add := func(p *Post) {
|
||||
if !seen[p.Slug] {
|
||||
seen[p.Slug] = true
|
||||
results = append(results, p)
|
||||
}
|
||||
}
|
||||
for _, p := range posts {
|
||||
if strings.Contains(strings.ToLower(p.Title), q) {
|
||||
add(p)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(p.Description), q) {
|
||||
add(p)
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
for _, t := range p.Tags {
|
||||
if strings.Contains(strings.ToLower(t), q) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
add(p)
|
||||
continue
|
||||
}
|
||||
// Fall back to raw markdown search (avoids rendering overhead)
|
||||
raw, err := s.RawContent(p.Slug)
|
||||
if err == nil && strings.Contains(strings.ToLower(raw), q) {
|
||||
add(p)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AllTags returns a deduplicated list of tags across all published posts.
|
||||
func (s *Store) AllTags() ([]string, error) {
|
||||
posts, err := s.All(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var tags []string
|
||||
for _, p := range posts {
|
||||
for _, t := range p.Tags {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(tags)
|
||||
return tags, nil
|
||||
}
|
||||
82
internal/feed/feed.go
Normal file
82
internal/feed/feed.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package feed generates RSS 2.0 / Atom feeds from blog posts.
|
||||
package feed
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
)
|
||||
|
||||
// --- RSS 2.0 ---
|
||||
|
||||
type rssChannel struct {
|
||||
XMLName xml.Name `xml:"channel"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
LastBuildDate string `xml:"lastBuildDate"`
|
||||
AtomLink atomLink `xml:"atom:link"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type atomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
GUID string `xml:"guid"`
|
||||
}
|
||||
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Atom string `xml:"xmlns:atom,attr"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
// RSS generates RSS 2.0 XML for the given posts.
|
||||
func RSS(siteURL, siteTitle, siteDesc string, posts []*blog.Post) ([]byte, error) {
|
||||
var items []rssItem
|
||||
for _, p := range posts {
|
||||
postURL := siteURL + "/blog/" + p.Slug
|
||||
items = append(items, rssItem{
|
||||
Title: p.Title,
|
||||
Link: postURL,
|
||||
Description: p.Description,
|
||||
PubDate: p.ParsedDate.Format(time.RFC1123Z),
|
||||
GUID: postURL,
|
||||
})
|
||||
}
|
||||
|
||||
feed := rssFeed{
|
||||
Version: "2.0",
|
||||
Atom: "http://www.w3.org/2005/Atom",
|
||||
Channel: rssChannel{
|
||||
Title: siteTitle,
|
||||
Link: siteURL,
|
||||
Description: siteDesc,
|
||||
Language: "en-us",
|
||||
LastBuildDate: time.Now().Format(time.RFC1123Z),
|
||||
AtomLink: atomLink{
|
||||
Href: siteURL + "/blog/feed.xml",
|
||||
Rel: "self",
|
||||
Type: "application/rss+xml",
|
||||
},
|
||||
Items: items,
|
||||
},
|
||||
}
|
||||
|
||||
out, err := xml.MarshalIndent(feed, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]byte(xml.Header), out...), nil
|
||||
}
|
||||
352
internal/handler/admin.go
Normal file
352
internal/handler/admin.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
|
||||
// AdminRouter dispatches /admin/* paths.
|
||||
func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
switch {
|
||||
case path == "/admin/login":
|
||||
if r.Method == http.MethodPost {
|
||||
h.adminLoginPost(w, r)
|
||||
} else {
|
||||
h.adminLoginGet(w, r)
|
||||
}
|
||||
|
||||
case path == "/admin/logout":
|
||||
h.requireAuth(h.adminLogout)(w, r)
|
||||
|
||||
case path == "/admin/new":
|
||||
if r.Method == http.MethodPost {
|
||||
h.requireAuth(h.adminNewPost)(w, r)
|
||||
} else {
|
||||
h.requireAuth(h.adminNewGet)(w, r)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(path, "/admin/edit/"):
|
||||
if r.Method == http.MethodPost {
|
||||
h.requireAuth(h.adminEditPost)(w, r)
|
||||
} else {
|
||||
h.requireAuth(h.adminEditGet)(w, r)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(path, "/admin/delete/"):
|
||||
h.requireAuth(h.adminDeletePost)(w, r)
|
||||
|
||||
case path == "/admin/status":
|
||||
if r.Method == http.MethodPost {
|
||||
h.requireAuth(h.adminStatusPost)(w, r)
|
||||
} else {
|
||||
h.requireAuth(h.adminStatusGet)(w, r)
|
||||
}
|
||||
|
||||
case path == "/admin/preview":
|
||||
h.requireAuth(h.adminPreview)(w, r)
|
||||
|
||||
case path == "/admin/upload":
|
||||
h.requireAuth(h.adminUpload)(w, r)
|
||||
|
||||
default:
|
||||
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
|
||||
}
|
||||
}
|
||||
|
||||
// AdminDashboard handles GET /admin.
|
||||
func (h *Handler) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/admin" && r.URL.Path != "/admin/" {
|
||||
// Fall through to router
|
||||
h.AdminRouter(w, r)
|
||||
return
|
||||
}
|
||||
h.requireAuth(h.adminDashboard)(w, r)
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
Posts []*blog.Post
|
||||
Flash string
|
||||
}
|
||||
|
||||
func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
flash := r.URL.Query().Get("flash")
|
||||
posts, err := h.store.All(true)
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
|
||||
return
|
||||
}
|
||||
h.render(w, "admin-dashboard", dashboardData{Posts: posts, Flash: flash})
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
|
||||
func (h *Handler) adminLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
if isAuthenticated(r) {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
h.render(w, "admin-login", map[string]string{"Error": ""})
|
||||
}
|
||||
|
||||
func (h *Handler) adminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
if !checkPassword(password) {
|
||||
h.render(w, "admin-login", map[string]string{"Error": "Invalid password."})
|
||||
return
|
||||
}
|
||||
setSession(w)
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
clearSession(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- New post ---
|
||||
|
||||
type editorData struct {
|
||||
Post *blog.Post
|
||||
Raw string
|
||||
IsNew bool
|
||||
Error string
|
||||
}
|
||||
|
||||
func (h *Handler) adminNewGet(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().Format("2006-01-02")
|
||||
raw := fmt.Sprintf("---\ntitle: New Post\ndate: %s\ntags: []\ndraft: true\ndescription: \"\"\n---\n\n", now)
|
||||
h.render(w, "admin-editor", editorData{Raw: raw, IsNew: true})
|
||||
}
|
||||
|
||||
func (h *Handler) adminNewPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
slug := sanitizeSlug(r.FormValue("slug"))
|
||||
content := r.FormValue("content")
|
||||
if slug == "" {
|
||||
h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Slug is required."})
|
||||
return
|
||||
}
|
||||
if err := h.store.Save(slug, content); err != nil {
|
||||
h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Failed to save: " + err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?flash=Post+created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Edit post ---
|
||||
|
||||
func (h *Handler) adminEditGet(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
|
||||
raw, err := h.store.RawContent(slug)
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusNotFound, "Post not found.")
|
||||
return
|
||||
}
|
||||
post, _ := h.store.Get(slug)
|
||||
h.render(w, "admin-editor", editorData{Post: post, Raw: raw, IsNew: false})
|
||||
}
|
||||
|
||||
func (h *Handler) adminEditPost(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
content := r.FormValue("content")
|
||||
if err := h.store.Save(slug, content); err != nil {
|
||||
h.render(w, "admin-editor", editorData{Raw: content, Error: "Failed to save: " + err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?flash=Post+saved", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Delete post ---
|
||||
|
||||
func (h *Handler) adminDeletePost(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
|
||||
return
|
||||
}
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/admin/delete/")
|
||||
if err := h.store.Delete(slug); err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin?flash=Post+deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Status editor ---
|
||||
|
||||
type adminStatusData struct {
|
||||
JSON string
|
||||
Error string
|
||||
Flash string
|
||||
}
|
||||
|
||||
func (h *Handler) adminStatusGet(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
|
||||
if err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{Error: "Could not load status.json: " + err.Error()})
|
||||
return
|
||||
}
|
||||
raw, _ := json.MarshalIndent(p, "", " ")
|
||||
flash := r.URL.Query().Get("flash")
|
||||
h.render(w, "admin-status", adminStatusData{JSON: string(raw), Flash: flash})
|
||||
}
|
||||
|
||||
func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
raw := r.FormValue("json")
|
||||
var p status.Page
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
p.LastChecked = time.Now().UTC()
|
||||
if err := status.Save(filepath.Join(h.dataDir, "status.json"), &p); err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Save failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/status?flash=Saved", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Preview ---
|
||||
|
||||
func (h *Handler) adminPreview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content := r.FormValue("content")
|
||||
html, err := blog.RenderMarkdown(content)
|
||||
if err != nil {
|
||||
http.Error(w, "render error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, "<div class='preview-body'>%s</div>", template.HTML(html))
|
||||
}
|
||||
|
||||
// --- Image upload ---
|
||||
|
||||
var allowedImageTypes = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
const maxUploadSize = 8 << 20 // 8 MB
|
||||
|
||||
func (h *Handler) adminUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"POST required"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, `{"error":"file too large (max 8 MB)"}`)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, `{"error":"no image field in form"}`)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read first 512 bytes to detect MIME type
|
||||
buf := make([]byte, 512)
|
||||
n, _ := file.Read(buf)
|
||||
mimeType := http.DetectContentType(buf[:n])
|
||||
|
||||
ext, ok := allowedImageTypes[mimeType]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, `{"error":"unsupported image type: %s"}`, mimeType)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a safe filename: sanitize original name + timestamp
|
||||
base := sanitizeSlug(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
if base == "" {
|
||||
base = "image"
|
||||
}
|
||||
filename := fmt.Sprintf("%s-%d%s", base, time.Now().UnixMilli(), ext)
|
||||
dest := filepath.Join("static", "uploads", filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `{"error":"could not create upload directory"}`)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `{"error":"could not save file"}`)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the already-read bytes, then the rest
|
||||
out.Write(buf[:n])
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `{"error":"write failed"}`)
|
||||
return
|
||||
}
|
||||
|
||||
url := "/static/uploads/" + filename
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"url":"%s","markdown":""}`, url, url)
|
||||
}
|
||||
|
||||
// sanitizeSlug ensures a slug is filesystem-safe.
|
||||
func sanitizeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
124
internal/handler/auth.go
Normal file
124
internal/handler/auth.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const sessionCookie = "rs_session"
|
||||
const sessionDuration = 24 * time.Hour
|
||||
|
||||
// sessionSecret returns the HMAC signing key from env, with a fallback warning.
|
||||
func sessionSecret() []byte {
|
||||
s := os.Getenv("SESSION_SECRET")
|
||||
if s == "" {
|
||||
// Insecure fallback for local dev only — set SESSION_SECRET in production
|
||||
return []byte("change-me-in-production")
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// adminPasswordHash returns the bcrypt hash of the admin password from env.
|
||||
func adminPasswordHash() string {
|
||||
return os.Getenv("ADMIN_PASSWORD_HASH")
|
||||
}
|
||||
|
||||
// checkPassword verifies a plaintext password against the stored bcrypt hash.
|
||||
func checkPassword(password string) bool {
|
||||
hash := adminPasswordHash()
|
||||
if hash == "" {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// signValue creates an HMAC-signed value: base64(payload)|base64(sig).
|
||||
func signValue(payload string) string {
|
||||
mac := hmac.New(sha256.New, sessionSecret())
|
||||
mac.Write([]byte(payload))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "|" + sig
|
||||
}
|
||||
|
||||
// verifyValue checks the signature and returns the original payload.
|
||||
func verifyValue(signed string) (string, bool) {
|
||||
parts := strings.SplitN(signed, "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", false
|
||||
}
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
payload := string(payloadBytes)
|
||||
expected := signValue(payload)
|
||||
if !hmac.Equal([]byte(signed), []byte(expected)) {
|
||||
return "", false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// setSession writes a signed session cookie.
|
||||
func setSession(w http.ResponseWriter) {
|
||||
expiry := time.Now().Add(sessionDuration).Format(time.RFC3339)
|
||||
value := signValue("admin|" + expiry)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: value,
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// clearSession deletes the session cookie.
|
||||
func clearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
// isAuthenticated returns true if the request has a valid session cookie.
|
||||
func isAuthenticated(r *http.Request) bool {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
payload, ok := verifyValue(c.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// payload = "admin|<RFC3339 expiry>"
|
||||
parts := strings.SplitN(payload, "|", 2)
|
||||
if len(parts) != 2 || parts[0] != "admin" {
|
||||
return false
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339, parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(expiry)
|
||||
}
|
||||
|
||||
// requireAuth is middleware that redirects to /admin/login if not authenticated.
|
||||
func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAuthenticated(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
105
internal/handler/handler.go
Normal file
105
internal/handler/handler.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package handler contains all HTTP request handlers.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
)
|
||||
|
||||
// Handler holds shared dependencies for all HTTP handlers.
|
||||
type Handler struct {
|
||||
store *blog.Store
|
||||
dataDir string
|
||||
templates map[string]*template.Template
|
||||
siteURL string
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// New creates a Handler. dataDir is the path to the data/ directory.
|
||||
func New(store *blog.Store, dataDir string) *Handler {
|
||||
h := &Handler{
|
||||
store: store,
|
||||
dataDir: dataDir,
|
||||
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
|
||||
devMode: os.Getenv("DEV") == "1",
|
||||
}
|
||||
if !h.devMode {
|
||||
h.templates = mustLoadTemplates()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// tmpl returns the template set for a given name, reloading in dev mode.
|
||||
func (h *Handler) tmpl(name string) *template.Template {
|
||||
if h.devMode {
|
||||
return mustLoadTemplates()[name]
|
||||
}
|
||||
return h.templates[name]
|
||||
}
|
||||
|
||||
func mustLoadTemplates() map[string]*template.Template {
|
||||
m := make(map[string]*template.Template)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": formatDate,
|
||||
"joinTags": joinTags,
|
||||
}
|
||||
|
||||
base := "templates/base.html"
|
||||
|
||||
pages := []struct {
|
||||
name string
|
||||
file string
|
||||
}{
|
||||
{"index", "templates/index.html"},
|
||||
{"blog", "templates/blog.html"},
|
||||
{"post", "templates/post.html"},
|
||||
{"infrastructure", "templates/infrastructure.html"},
|
||||
{"status", "templates/status.html"},
|
||||
{"about", "templates/about.html"},
|
||||
{"admin-login", "templates/admin/login.html"},
|
||||
{"admin-dashboard", "templates/admin/dashboard.html"},
|
||||
{"admin-editor", "templates/admin/editor.html"},
|
||||
{"admin-status", "templates/admin/status.html"},
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
t, err := template.New(filepath.Base(p.file)).Funcs(funcMap).ParseFiles(base, p.file)
|
||||
if err != nil {
|
||||
log.Fatalf("template %s: %v", p.name, err)
|
||||
}
|
||||
m[p.name] = t
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
|
||||
t := h.tmpl(name)
|
||||
if t == nil {
|
||||
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
w.Write([]byte("<html><body><h1>" + http.StatusText(code) + "</h1><p>" + msg + "</p><a href='/'>Home</a></body></html>"))
|
||||
}
|
||||
17
internal/handler/helpers.go
Normal file
17
internal/handler/helpers.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formatDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2 January 2006")
|
||||
}
|
||||
|
||||
func joinTags(tags []string) string {
|
||||
return strings.Join(tags, ", ")
|
||||
}
|
||||
219
internal/handler/public.go
Normal file
219
internal/handler/public.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/feed"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
|
||||
const postsPerPage = 10
|
||||
|
||||
// indexData is passed to the index template.
|
||||
type indexData struct {
|
||||
RecentPosts []*blog.Post
|
||||
}
|
||||
|
||||
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
h.renderErr(w, http.StatusNotFound, "Page not found.")
|
||||
return
|
||||
}
|
||||
posts, err := h.store.All(false)
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
|
||||
return
|
||||
}
|
||||
limit := 5
|
||||
if len(posts) < limit {
|
||||
limit = len(posts)
|
||||
}
|
||||
h.render(w, "index", indexData{RecentPosts: posts[:limit]})
|
||||
}
|
||||
|
||||
// blogData is passed to the blog list template.
|
||||
type blogData struct {
|
||||
Posts []*blog.Post
|
||||
Tags []string
|
||||
ActiveTag string
|
||||
SearchQuery string
|
||||
Page int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
func (h *Handler) BlogList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
tag := q.Get("tag")
|
||||
search := strings.TrimSpace(q.Get("q"))
|
||||
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
var posts []*blog.Post
|
||||
var err error
|
||||
switch {
|
||||
case search != "":
|
||||
posts, err = h.store.Search(search)
|
||||
case tag != "":
|
||||
posts, err = h.store.ByTag(tag)
|
||||
default:
|
||||
posts, err = h.store.All(false)
|
||||
}
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
|
||||
return
|
||||
}
|
||||
|
||||
tags, _ := h.store.AllTags()
|
||||
|
||||
// Paginate
|
||||
total := len(posts)
|
||||
totalPages := (total + postsPerPage - 1) / postsPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
start := (page - 1) * postsPerPage
|
||||
end := start + postsPerPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
h.render(w, "blog", blogData{
|
||||
Posts: posts[start:end],
|
||||
Tags: tags,
|
||||
ActiveTag: tag,
|
||||
SearchQuery: search,
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
http.Redirect(w, r, "/blog", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
post, err := h.store.Get(slug)
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusNotFound, "Post not found.")
|
||||
return
|
||||
}
|
||||
// Drafts are visible to authenticated admins only
|
||||
if post.Draft && !isAuthenticated(r) {
|
||||
h.renderErr(w, http.StatusNotFound, "Post not found.")
|
||||
return
|
||||
}
|
||||
h.render(w, "post", post)
|
||||
}
|
||||
|
||||
func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
posts, err := h.store.All(false)
|
||||
if err != nil {
|
||||
http.Error(w, "feed unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on OpenBSD.", posts)
|
||||
if err != nil {
|
||||
http.Error(w, "feed error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Write(rss)
|
||||
}
|
||||
|
||||
func (h *Handler) Infrastructure(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "infrastructure", nil)
|
||||
}
|
||||
|
||||
// statusData is passed to the status template.
|
||||
type statusData struct {
|
||||
Page *status.Page
|
||||
LastChecked string
|
||||
}
|
||||
|
||||
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
|
||||
if err != nil {
|
||||
p = &status.Page{LastChecked: time.Now(), Services: []status.Service{}}
|
||||
}
|
||||
var lastChecked string
|
||||
if !p.LastChecked.IsZero() {
|
||||
lastChecked = p.LastChecked.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked})
|
||||
}
|
||||
|
||||
func (h *Handler) About(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "about", nil)
|
||||
}
|
||||
|
||||
// --- Sitemap ---
|
||||
|
||||
type urlset struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
URLs []sitemapURL `xml:"url"`
|
||||
}
|
||||
|
||||
type sitemapURL struct {
|
||||
Loc string `xml:"loc"`
|
||||
LastMod string `xml:"lastmod,omitempty"`
|
||||
Freq string `xml:"changefreq,omitempty"`
|
||||
Prio string `xml:"priority,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
||||
posts, _ := h.store.All(false)
|
||||
|
||||
urls := []sitemapURL{
|
||||
{Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"},
|
||||
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
|
||||
{Loc: h.siteURL + "/infrastructure", Freq: "monthly", Prio: "0.7"},
|
||||
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
|
||||
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
|
||||
}
|
||||
|
||||
for _, p := range posts {
|
||||
u := sitemapURL{
|
||||
Loc: h.siteURL + "/blog/" + p.Slug,
|
||||
Freq: "never",
|
||||
Prio: "0.8",
|
||||
}
|
||||
if !p.ParsedDate.IsZero() {
|
||||
u.LastMod = p.ParsedDate.Format("2006-01-02")
|
||||
}
|
||||
urls = append(urls, u)
|
||||
}
|
||||
|
||||
out, err := xml.MarshalIndent(urlset{
|
||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: urls,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, "sitemap error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.Write([]byte(xml.Header))
|
||||
w.Write(out)
|
||||
}
|
||||
46
internal/status/status.go
Normal file
46
internal/status/status.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Package status loads and manages the service status JSON.
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service represents a single monitored service.
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Status string `json:"status"` // "up", "degraded", "down", "unknown"
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Page is the full status page data loaded from JSON.
|
||||
type Page struct {
|
||||
LastChecked time.Time `json:"last_checked"`
|
||||
Services []Service `json:"services"`
|
||||
}
|
||||
|
||||
// Load reads and parses the status JSON from path.
|
||||
func Load(path string) (*Page, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p Page
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Save writes the status page data back to path.
|
||||
func Save(path string, p *Page) error {
|
||||
p.LastChecked = time.Now().UTC()
|
||||
raw, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user