DOCS-1: Init document work
This commit is contained in:
690
cmd/build/main.go
Normal file
690
cmd/build/main.go
Normal file
@@ -0,0 +1,690 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Page struct {
|
||||
Title string
|
||||
Description string
|
||||
Section string
|
||||
Order int
|
||||
Slug string
|
||||
URL string
|
||||
Content template.HTML
|
||||
Excerpt string
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Slug string
|
||||
Title string
|
||||
Pages []*Page
|
||||
}
|
||||
|
||||
type SidebarPage struct {
|
||||
Title string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type SidebarSection struct {
|
||||
Title string
|
||||
URL string
|
||||
Pages []SidebarPage
|
||||
}
|
||||
|
||||
type Breadcrumb struct {
|
||||
Label string
|
||||
URL string
|
||||
}
|
||||
|
||||
type PageLink struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Description string
|
||||
Content template.HTML
|
||||
Root string
|
||||
Canonical string
|
||||
Nav []SidebarSection
|
||||
Breadcrumbs []Breadcrumb
|
||||
Prev *PageLink
|
||||
Next *PageLink
|
||||
Year int
|
||||
Customer any
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
type SearchDoc struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Section string `json:"section"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
}
|
||||
|
||||
type RSSItem struct {
|
||||
Title string
|
||||
URL string
|
||||
Description string
|
||||
Section string
|
||||
}
|
||||
|
||||
// ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
siteBaseURL = "https://docs.arcline.it"
|
||||
distDir = "dist"
|
||||
contentDir = "content"
|
||||
staticDir = "static"
|
||||
)
|
||||
|
||||
var sectionMeta = []struct {
|
||||
Slug string
|
||||
Title string
|
||||
}{
|
||||
{"getting-started", "Getting Started"},
|
||||
{"migrate", "Migration Guides"},
|
||||
{"wordpress", "WordPress"},
|
||||
{"vps", "VPS Guides"},
|
||||
{"privacy", "Privacy & Self-Hosting"},
|
||||
{"reference", "Reference"},
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
watch := false
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--watch" {
|
||||
watch = true
|
||||
}
|
||||
}
|
||||
|
||||
if watch {
|
||||
runWatch()
|
||||
} else {
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
func build() {
|
||||
start := time.Now()
|
||||
|
||||
if err := os.RemoveAll(distDir); err != nil {
|
||||
die("rm dist: %v", err)
|
||||
}
|
||||
|
||||
pages, err := collectPages()
|
||||
if err != nil {
|
||||
die("collect pages: %v", err)
|
||||
}
|
||||
|
||||
sections := buildSections(pages)
|
||||
|
||||
tmpl := template.New("").Funcs(template.FuncMap{
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
})
|
||||
tmpl, err = template.ParseFiles("templates/page.html", "templates/layout.html")
|
||||
if err != nil {
|
||||
die("parse template: %v", err)
|
||||
}
|
||||
|
||||
for _, sec := range sections {
|
||||
for i, page := range sec.Pages {
|
||||
var prev, next *PageLink
|
||||
if i > 0 {
|
||||
prev = &PageLink{Title: sec.Pages[i-1].Title, URL: sec.Pages[i-1].URL}
|
||||
}
|
||||
if i < len(sec.Pages)-1 {
|
||||
next = &PageLink{Title: sec.Pages[i+1].Title, URL: sec.Pages[i+1].URL}
|
||||
}
|
||||
if err := renderPage(tmpl, page, sections, sec, prev, next); err != nil {
|
||||
die("render %s: %v", page.URL, err)
|
||||
}
|
||||
}
|
||||
if err := renderSectionIndex(tmpl, sec, sections); err != nil {
|
||||
die("render section index %s: %v", sec.Slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := renderHome(tmpl, sections); err != nil {
|
||||
die("render home: %v", err)
|
||||
}
|
||||
if err := render404(tmpl); err != nil {
|
||||
die("render 404: %v", err)
|
||||
}
|
||||
if err := copyDir(staticDir, distDir); err != nil {
|
||||
die("copy static: %v", err)
|
||||
}
|
||||
|
||||
fontSrc := filepath.Join("..", "website", "static", "public", "fonts")
|
||||
if info, err := os.Stat(fontSrc); err == nil && info.IsDir() {
|
||||
fontDst := filepath.Join(distDir, "public", "fonts")
|
||||
if err := copyDir(fontSrc, fontDst); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: copy fonts: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := generateSearch(pages, sections); err != nil {
|
||||
die("generate search: %v", err)
|
||||
}
|
||||
if err := generateSitemap(pages, sections); err != nil {
|
||||
die("generate sitemap: %v", err)
|
||||
}
|
||||
if err := generateRSS(pages, sections); err != nil {
|
||||
die("generate rss: %v", err)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, sec := range sections {
|
||||
total += len(sec.Pages)
|
||||
}
|
||||
fmt.Printf("Built %d pages in %s → %s/\n", total, time.Since(start).Round(time.Millisecond), distDir)
|
||||
}
|
||||
|
||||
func runWatch() {
|
||||
fmt.Println("Watching for changes (Ctrl+C to stop)...")
|
||||
build()
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
die("fsnotify: %v", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
for _, dir := range []string{contentDir, "templates", staticDir} {
|
||||
if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return watcher.Add(path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
die("watch add %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
debounce := time.NewTimer(0)
|
||||
if !debounce.Stop() {
|
||||
<-debounce.C
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-watcher.Events:
|
||||
if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove) != 0 {
|
||||
debounce.Reset(300 * time.Millisecond)
|
||||
}
|
||||
case err := <-watcher.Errors:
|
||||
slog.Warn("watch error", "err", err)
|
||||
case <-debounce.C:
|
||||
fmt.Println("\nChange detected, rebuilding...")
|
||||
build()
|
||||
fmt.Println("Watching for changes (Ctrl+C to stop)...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── collect ───────────────────────────────────────────────────────────────────
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.Table,
|
||||
extension.Strikethrough,
|
||||
extension.TaskList,
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
goldmarkhtml.WithUnsafe(),
|
||||
),
|
||||
)
|
||||
|
||||
func collectPages() ([]*Page, error) {
|
||||
var pages []*Page
|
||||
|
||||
err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
fm, body := parseFrontmatter(raw)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(body, &buf); err != nil {
|
||||
return fmt.Errorf("convert %s: %w", path, err)
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(contentDir, path)
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
|
||||
var section, slug string
|
||||
if len(parts) == 1 {
|
||||
return nil
|
||||
}
|
||||
section = parts[0]
|
||||
slug = strings.TrimSuffix(parts[len(parts)-1], ".md")
|
||||
|
||||
url := "/" + section + "/" + slug + "/"
|
||||
order, _ := strconv.Atoi(fm["order"])
|
||||
htmlContent := buf.String()
|
||||
|
||||
pages = append(pages, &Page{
|
||||
Title: fm["title"],
|
||||
Description: fm["description"],
|
||||
Section: section,
|
||||
Order: order,
|
||||
Slug: slug,
|
||||
URL: url,
|
||||
Content: template.HTML(htmlContent),
|
||||
Excerpt: htmlExcerpt(htmlContent, 220),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
return pages, err
|
||||
}
|
||||
|
||||
func parseFrontmatter(raw []byte) (map[string]string, []byte) {
|
||||
fm := make(map[string]string)
|
||||
s := string(raw)
|
||||
if !strings.HasPrefix(s, "---") {
|
||||
return fm, raw
|
||||
}
|
||||
rest := s[3:]
|
||||
if rest != "" && rest[0] == '\n' {
|
||||
rest = rest[1:]
|
||||
}
|
||||
end := strings.Index(rest, "---")
|
||||
if end < 0 {
|
||||
return fm, raw
|
||||
}
|
||||
block := rest[:end]
|
||||
body := rest[end+3:]
|
||||
if len(body) > 0 && body[0] == '\n' {
|
||||
body = body[1:]
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(line[:idx])
|
||||
v := strings.TrimSpace(line[idx+1:])
|
||||
v = strings.Trim(v, `"'`)
|
||||
fm[k] = v
|
||||
}
|
||||
return fm, []byte(body)
|
||||
}
|
||||
|
||||
var tagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
func htmlExcerpt(h string, maxLen int) string {
|
||||
text := tagRe.ReplaceAllString(h, " ")
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if len([]rune(text)) > maxLen {
|
||||
r := []rune(text)[:maxLen]
|
||||
text = string(r) + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ── sections ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func buildSections(pages []*Page) []*Section {
|
||||
bySlug := make(map[string]*Section)
|
||||
for _, sm := range sectionMeta {
|
||||
bySlug[sm.Slug] = &Section{Slug: sm.Slug, Title: sm.Title}
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
sec := bySlug[p.Section]
|
||||
if sec == nil {
|
||||
sec = &Section{
|
||||
Slug: p.Section,
|
||||
Title: strings.ReplaceAll(strings.ToTitle(p.Section[:1])+p.Section[1:], "-", " "),
|
||||
}
|
||||
bySlug[p.Section] = sec
|
||||
}
|
||||
sec.Pages = append(sec.Pages, p)
|
||||
}
|
||||
|
||||
for _, sec := range bySlug {
|
||||
sort.Slice(sec.Pages, func(i, j int) bool {
|
||||
if sec.Pages[i].Order != sec.Pages[j].Order {
|
||||
return sec.Pages[i].Order < sec.Pages[j].Order
|
||||
}
|
||||
return sec.Pages[i].Title < sec.Pages[j].Title
|
||||
})
|
||||
}
|
||||
|
||||
var out []*Section
|
||||
for _, sm := range sectionMeta {
|
||||
if sec := bySlug[sm.Slug]; sec != nil && len(sec.Pages) > 0 {
|
||||
out = append(out, sec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildNav(sections []*Section, currentURL string) []SidebarSection {
|
||||
nav := make([]SidebarSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
pages := make([]SidebarPage, 0, len(sec.Pages))
|
||||
for _, p := range sec.Pages {
|
||||
pages = append(pages, SidebarPage{
|
||||
Title: p.Title,
|
||||
URL: p.URL,
|
||||
Active: p.URL == currentURL,
|
||||
})
|
||||
}
|
||||
nav = append(nav, SidebarSection{
|
||||
Title: sec.Title,
|
||||
URL: "/" + sec.Slug + "/",
|
||||
Pages: pages,
|
||||
})
|
||||
}
|
||||
return nav
|
||||
}
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderPage(tmpl *template.Template, page *Page, sections []*Section, sec *Section, prev, next *PageLink) error {
|
||||
outPath := filepath.Join(distDir, page.Section, page.Slug, "index.html")
|
||||
f := mustCreate(outPath)
|
||||
defer f.Close()
|
||||
|
||||
return tmpl.Execute(f, PageData{
|
||||
Title: page.Title + " — Arcline Docs",
|
||||
Description: page.Description,
|
||||
Content: page.Content,
|
||||
Root: "../../",
|
||||
Canonical: siteBaseURL + page.URL,
|
||||
Nav: buildNav(sections, page.URL),
|
||||
Breadcrumbs: []Breadcrumb{
|
||||
{Label: sec.Title, URL: "/" + sec.Slug + "/"},
|
||||
{Label: page.Title},
|
||||
},
|
||||
Prev: prev,
|
||||
Next: next,
|
||||
Year: time.Now().Year(),
|
||||
})
|
||||
}
|
||||
|
||||
func renderSectionIndex(tmpl *template.Template, sec *Section, sections []*Section) error {
|
||||
outPath := filepath.Join(distDir, sec.Slug, "index.html")
|
||||
f := mustCreate(outPath)
|
||||
defer f.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`<div class="section-index">`)
|
||||
for _, p := range sec.Pages {
|
||||
fmt.Fprintf(&buf, `<a href="%s" class="section-index__card">`, p.URL)
|
||||
fmt.Fprintf(&buf, `<span class="section-index__title">%s</span>`, template.HTMLEscapeString(p.Title))
|
||||
if p.Description != "" {
|
||||
fmt.Fprintf(&buf, `<span class="section-index__desc">%s</span>`, template.HTMLEscapeString(p.Description))
|
||||
}
|
||||
buf.WriteString(`</a>`)
|
||||
}
|
||||
buf.WriteString(`</div>`)
|
||||
|
||||
return tmpl.Execute(f, PageData{
|
||||
Title: sec.Title + " — Arcline Docs",
|
||||
Description: "Guides in the " + sec.Title + " section.",
|
||||
Content: template.HTML(buf.String()),
|
||||
Root: "../",
|
||||
Canonical: siteBaseURL + "/" + sec.Slug + "/",
|
||||
Nav: buildNav(sections, "/"+sec.Slug+"/"),
|
||||
Breadcrumbs: []Breadcrumb{{Label: sec.Title}},
|
||||
Year: time.Now().Year(),
|
||||
})
|
||||
}
|
||||
|
||||
func renderHome(tmpl *template.Template, sections []*Section) error {
|
||||
outPath := filepath.Join(distDir, "index.html")
|
||||
f := mustCreate(outPath)
|
||||
defer f.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(`<p class="docs-home__intro">Find guides on getting connected, migrating from other hosts, and managing your Arcline hosting account.</p>`)
|
||||
for _, sec := range sections {
|
||||
fmt.Fprintf(&buf, `<div class="docs-home__section"><h2 class="docs-home__section-title"><a href="/%s/">%s</a></h2><ul class="docs-home__list">`,
|
||||
sec.Slug, template.HTMLEscapeString(sec.Title))
|
||||
for _, p := range sec.Pages {
|
||||
fmt.Fprintf(&buf, `<li><a href="%s">%s</a>`, p.URL, template.HTMLEscapeString(p.Title))
|
||||
if p.Description != "" {
|
||||
fmt.Fprintf(&buf, ` — <span class="docs-home__desc">%s</span>`, template.HTMLEscapeString(p.Description))
|
||||
}
|
||||
buf.WriteString(`</li>`)
|
||||
}
|
||||
buf.WriteString(`</ul></div>`)
|
||||
}
|
||||
|
||||
return tmpl.Execute(f, PageData{
|
||||
Title: "Arcline Docs — Knowledge Base",
|
||||
Description: "Guides, tutorials, and reference docs for Arcline hosting customers.",
|
||||
Content: template.HTML(buf.String()),
|
||||
Root: "",
|
||||
Canonical: siteBaseURL + "/",
|
||||
Nav: buildNav(sections, "/"),
|
||||
Year: time.Now().Year(),
|
||||
})
|
||||
}
|
||||
|
||||
func render404(tmpl *template.Template) error {
|
||||
outPath := filepath.Join(distDir, "404.html")
|
||||
f := mustCreate(outPath)
|
||||
defer f.Close()
|
||||
|
||||
content := template.HTML(`
|
||||
<div class="docs-article docs-404">
|
||||
<h1>Page not found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<p><a href="/" class="btn btn--primary">← Back to docs home</a></p>
|
||||
</div>`)
|
||||
|
||||
return tmpl.Execute(f, PageData{
|
||||
Title: "Page not found — Arcline Docs",
|
||||
Description: "The page you're looking for doesn't exist or has been moved.",
|
||||
Content: content,
|
||||
Root: "",
|
||||
Canonical: "",
|
||||
Nav: nil,
|
||||
Year: time.Now().Year(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── static assets ─────────────────────────────────────────────────────────────
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, path)
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0755)
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func mustCreate(path string) *os.File {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
die("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
die("create %s: %v", path, err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ── search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateSearch(pages []*Page, sections []*Section) error {
|
||||
secTitle := make(map[string]string, len(sections))
|
||||
for _, sec := range sections {
|
||||
secTitle[sec.Slug] = sec.Title
|
||||
}
|
||||
|
||||
docs := make([]SearchDoc, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
docs = append(docs, SearchDoc{
|
||||
Title: p.Title,
|
||||
URL: p.URL,
|
||||
Section: secTitle[p.Section],
|
||||
Excerpt: p.Excerpt,
|
||||
})
|
||||
}
|
||||
|
||||
data, err := json.Marshal(docs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(distDir, "search.json"), data, 0644)
|
||||
}
|
||||
|
||||
// ── sitemap ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateSitemap(pages []*Page, sections []*Section) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
b.WriteString("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n")
|
||||
|
||||
add := func(url, priority string) {
|
||||
fmt.Fprintf(&b, " <url>\n <loc>%s%s</loc>\n <changefreq>monthly</changefreq>\n <priority>%s</priority>\n </url>\n",
|
||||
siteBaseURL, url, priority)
|
||||
}
|
||||
|
||||
add("/", "1.0")
|
||||
for _, sec := range sections {
|
||||
add("/"+sec.Slug+"/", "0.8")
|
||||
for _, p := range sec.Pages {
|
||||
add(p.URL, "0.7")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("</urlset>\n")
|
||||
return os.WriteFile(filepath.Join(distDir, "sitemap.xml"), []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
// ── RSS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateRSS(pages []*Page, sections []*Section) error {
|
||||
secTitle := make(map[string]string, len(sections))
|
||||
for _, sec := range sections {
|
||||
secTitle[sec.Slug] = sec.Title
|
||||
}
|
||||
|
||||
var items []RSSItem
|
||||
for _, sec := range sections {
|
||||
for _, p := range sec.Pages {
|
||||
items = append(items, RSSItem{
|
||||
Title: p.Title,
|
||||
URL: siteBaseURL + p.URL,
|
||||
Description: p.Description,
|
||||
Section: secTitle[p.Section],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
b.WriteString("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n")
|
||||
b.WriteString("<channel>\n")
|
||||
fmt.Fprintf(&b, " <title>Arcline Docs</title>\n")
|
||||
fmt.Fprintf(&b, " <link>%s/</link>\n", siteBaseURL)
|
||||
fmt.Fprintf(&b, " <description>Guides, tutorials, and reference docs for Arcline hosting customers.</description>\n")
|
||||
fmt.Fprintf(&b, " <language>en-us</language>\n")
|
||||
fmt.Fprintf(&b, " <lastBuildDate>%s</lastBuildDate>\n", now)
|
||||
fmt.Fprintf(&b, " <atom:link href=\"%s/rss.xml\" rel=\"self\" type=\"application/rss+xml\"/>\n", siteBaseURL)
|
||||
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, " <item>\n")
|
||||
fmt.Fprintf(&b, " <title>%s</title>\n", escapeXML(item.Title))
|
||||
fmt.Fprintf(&b, " <link>%s</link>\n", escapeXML(item.URL))
|
||||
fmt.Fprintf(&b, " <guid>%s</guid>\n", escapeXML(item.URL))
|
||||
fmt.Fprintf(&b, " <description>%s</description>\n", escapeXML(item.Description))
|
||||
fmt.Fprintf(&b, " <category>%s</category>\n", escapeXML(item.Section))
|
||||
fmt.Fprintf(&b, " </item>\n")
|
||||
}
|
||||
|
||||
b.WriteString("</channel>\n")
|
||||
b.WriteString("</rss>\n")
|
||||
return os.WriteFile(filepath.Join(distDir, "rss.xml"), []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
762
cmd/serve/main.go
Normal file
762
cmd/serve/main.go
Normal file
@@ -0,0 +1,762 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"arclineit.com/docs/internal/store"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
BillingDB string
|
||||
DocsDB string
|
||||
AdminEmail string
|
||||
BillingURL string
|
||||
ContentDir string
|
||||
StaticDir string
|
||||
TemplatesDir string
|
||||
}
|
||||
|
||||
func configFromEnv() Config {
|
||||
get := func(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
return Config{
|
||||
Port: get("PORT", "8080"),
|
||||
BillingDB: get("BILLING_DB", "local/billing.db"),
|
||||
DocsDB: get("DOCS_DB", "local/docs.db"),
|
||||
AdminEmail: get("ADMIN_EMAIL", ""),
|
||||
BillingURL: get("BILLING_URL", "https://portal.arcline.it"),
|
||||
ContentDir: get("CONTENT_DIR", "content"),
|
||||
StaticDir: get("STATIC_DIR", "static"),
|
||||
TemplatesDir: get("TEMPLATES_DIR", "templates"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markdown ───────────────────────────────────────────────────────────────────
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(goldmarkhtml.WithUnsafe()),
|
||||
)
|
||||
|
||||
var frontmatterRe = regexp.MustCompile(`(?s)^---\n(.+?)\n---\n?`)
|
||||
|
||||
type frontmatter struct {
|
||||
Title string
|
||||
Description string
|
||||
Section string
|
||||
Order int
|
||||
}
|
||||
|
||||
func parseFrontmatter(src []byte) (frontmatter, []byte) {
|
||||
m := frontmatterRe.FindSubmatch(src)
|
||||
if m == nil {
|
||||
return frontmatter{}, src
|
||||
}
|
||||
var fm frontmatter
|
||||
for _, line := range strings.Split(string(m[1]), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
switch strings.TrimSpace(k) {
|
||||
case "title":
|
||||
fm.Title = strings.Trim(v, `"`)
|
||||
case "description":
|
||||
fm.Description = strings.Trim(v, `"`)
|
||||
case "section":
|
||||
fm.Section = v
|
||||
case "order":
|
||||
fm.Order, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
return fm, src[len(m[0]):]
|
||||
}
|
||||
|
||||
func renderMarkdown(src []byte) (template.HTML, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(src, &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
|
||||
// ── Content loading ────────────────────────────────────────────────────────────
|
||||
|
||||
type publicPage struct {
|
||||
Title string
|
||||
Description string
|
||||
Section string
|
||||
Slug string
|
||||
URL string
|
||||
Order int
|
||||
RawContent []byte
|
||||
}
|
||||
|
||||
type publicSection struct {
|
||||
Slug string
|
||||
Title string
|
||||
Pages []*publicPage
|
||||
}
|
||||
|
||||
var sectionMeta = []struct{ Slug, Title string }{
|
||||
{"getting-started", "Getting Started"},
|
||||
{"migrate", "Migration Guides"},
|
||||
{"wordpress", "WordPress"},
|
||||
{"vps", "VPS Guides"},
|
||||
{"privacy", "Privacy & Self-Hosting"},
|
||||
{"reference", "Reference"},
|
||||
}
|
||||
|
||||
func loadContent(contentDir string) ([]*publicSection, error) {
|
||||
bySlug := map[string]*publicSection{}
|
||||
|
||||
err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fm, body := parseFrontmatter(raw)
|
||||
|
||||
rel, _ := filepath.Rel(contentDir, path)
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
secSlug := parts[0]
|
||||
slug := strings.TrimSuffix(parts[1], ".md")
|
||||
|
||||
if _, ok := bySlug[secSlug]; !ok {
|
||||
title := secSlug
|
||||
for _, m := range sectionMeta {
|
||||
if m.Slug == secSlug {
|
||||
title = m.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
bySlug[secSlug] = &publicSection{Slug: secSlug, Title: title}
|
||||
}
|
||||
|
||||
bySlug[secSlug].Pages = append(bySlug[secSlug].Pages, &publicPage{
|
||||
Title: fm.Title,
|
||||
Description: fm.Description,
|
||||
Section: secSlug,
|
||||
Slug: slug,
|
||||
URL: "/" + secSlug + "/" + slug + "/",
|
||||
Order: fm.Order,
|
||||
RawContent: body,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sections []*publicSection
|
||||
for _, m := range sectionMeta {
|
||||
if sec, ok := bySlug[m.Slug]; ok {
|
||||
sort.Slice(sec.Pages, func(i, j int) bool {
|
||||
return sec.Pages[i].Order < sec.Pages[j].Order
|
||||
})
|
||||
sections = append(sections, sec)
|
||||
}
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// ── Template data ──────────────────────────────────────────────────────────────
|
||||
|
||||
type navPage struct{ Title, URL string; Active bool }
|
||||
type navSection struct{ Title, URL string; Pages []navPage }
|
||||
type breadcrumb struct{ Label, URL string }
|
||||
type pageLink struct{ Title, URL string }
|
||||
|
||||
// TD is the single template data struct used by every template.
|
||||
type TD struct {
|
||||
// Page meta
|
||||
Title, Description string
|
||||
Content template.HTML
|
||||
Canonical string
|
||||
Year int
|
||||
|
||||
// Auth
|
||||
Customer *store.Customer
|
||||
IsAdmin bool
|
||||
|
||||
// Public doc sidebar/nav (non-nil on public doc pages only)
|
||||
Nav []navSection
|
||||
Breadcrumbs []breadcrumb
|
||||
Prev, Next *pageLink
|
||||
|
||||
// Client docs listing
|
||||
Pages []*store.Page
|
||||
|
||||
// Single client/admin page
|
||||
Page *store.Page
|
||||
|
||||
// Admin edit form
|
||||
PlanOptions []store.PlanOption
|
||||
Customers []store.Customer
|
||||
Error string
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// ── Handler ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type handler struct {
|
||||
cfg Config
|
||||
st *store.Store
|
||||
tmpl *template.Template
|
||||
sections []*publicSection
|
||||
}
|
||||
|
||||
func newHandler(cfg Config, st *store.Store) (*handler, error) {
|
||||
sections, err := loadContent(cfg.ContentDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load content: %w", err)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(cfg.TemplatesDir + "/*.html")
|
||||
if err != nil || len(files) == 0 {
|
||||
return nil, fmt.Errorf("no templates in %s", cfg.TemplatesDir)
|
||||
}
|
||||
funcs := template.FuncMap{
|
||||
"visLabel": store.VisibilityLabel,
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(funcs).ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
|
||||
return &handler{cfg: cfg, st: st, tmpl: tmpl, sections: sections}, nil
|
||||
}
|
||||
|
||||
func (h *handler) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
slog.Error("template error", "name", name, "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) currentCustomer(r *http.Request) (*store.Customer, *store.Subscription) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
c, err := h.st.GetCustomerBySession(cookie.Value)
|
||||
if err != nil || c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
sub, _ := h.st.GetSubscription(c.ID)
|
||||
return c, sub
|
||||
}
|
||||
|
||||
func (h *handler) isAdmin(c *store.Customer) bool {
|
||||
return c != nil && h.cfg.AdminEmail != "" && c.Email == h.cfg.AdminEmail
|
||||
}
|
||||
|
||||
func (h *handler) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, _ := h.currentCustomer(r); c == nil {
|
||||
http.Redirect(w, r, h.cfg.BillingURL+"/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, _ := h.currentCustomer(r)
|
||||
if !h.isAdmin(c) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Nav ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) buildNav(activeSec, activeSlug string) []navSection {
|
||||
nav := make([]navSection, 0, len(h.sections))
|
||||
for _, sec := range h.sections {
|
||||
pages := make([]navPage, 0, len(sec.Pages))
|
||||
for _, p := range sec.Pages {
|
||||
pages = append(pages, navPage{
|
||||
Title: p.Title,
|
||||
URL: p.URL,
|
||||
Active: sec.Slug == activeSec && p.Slug == activeSlug,
|
||||
})
|
||||
}
|
||||
nav = append(nav, navSection{Title: sec.Title, URL: "/" + sec.Slug + "/", Pages: pages})
|
||||
}
|
||||
return nav
|
||||
}
|
||||
|
||||
// ── Public handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) home(w http.ResponseWriter, r *http.Request) {
|
||||
c, _ := h.currentCustomer(r)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`<div class="docs-article"><h1>Arcline Documentation</h1>` +
|
||||
`<p>Guides for getting started with Arcline hosting, migrating from other providers, and managing your account.</p>`)
|
||||
for _, sec := range h.sections {
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
`<div class="docs-home__section"><h2 class="docs-home__section-title"><a href="/%s/">%s</a></h2><ul class="docs-home__list">`,
|
||||
sec.Slug, sec.Title,
|
||||
))
|
||||
for _, p := range sec.Pages {
|
||||
sb.WriteString(fmt.Sprintf(`<li><a href="%s">%s</a>`, p.URL, p.Title))
|
||||
if p.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf(` <span class="docs-home__desc">— %s</span>`, p.Description))
|
||||
}
|
||||
sb.WriteString(`</li>`)
|
||||
}
|
||||
sb.WriteString(`</ul></div>`)
|
||||
}
|
||||
if c != nil {
|
||||
sb.WriteString(`<div class="docs-home__section"><h2 class="docs-home__section-title">` +
|
||||
`<a href="/client/">My Docs</a></h2>` +
|
||||
`<p class="docs-home__desc">Private guides and documentation specific to your account.</p></div>`)
|
||||
}
|
||||
sb.WriteString(`</div>`)
|
||||
|
||||
h.render(w, "page.html", TD{
|
||||
Title: "Arcline Documentation",
|
||||
Canonical: "https://docs.arcline.it/",
|
||||
Nav: h.buildNav("", ""),
|
||||
Year: time.Now().Year(),
|
||||
Content: template.HTML(sb.String()),
|
||||
Customer: c, IsAdmin: h.isAdmin(c),
|
||||
})
|
||||
}
|
||||
|
||||
// pathParts splits a URL path into its non-empty segments.
|
||||
// "/getting-started/ssh/" → ["getting-started", "ssh"]
|
||||
func pathParts(p string) []string {
|
||||
var parts []string
|
||||
for _, s := range strings.Split(strings.Trim(p, "/"), "/") {
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (h *handler) sectionIndex(w http.ResponseWriter, r *http.Request) {
|
||||
parts := pathParts(r.URL.Path)
|
||||
if len(parts) != 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slug := parts[0]
|
||||
var sec *publicSection
|
||||
for _, s := range h.sections {
|
||||
if s.Slug == slug {
|
||||
sec = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if sec == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
c, _ := h.currentCustomer(r)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
`<div class="docs-article"><h1>%s</h1><div class="section-index">`, sec.Title,
|
||||
))
|
||||
for _, p := range sec.Pages {
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
`<a href="%s" class="section-index__card"><span class="section-index__title">%s</span>`+
|
||||
`<span class="section-index__desc">%s</span></a>`,
|
||||
p.URL, p.Title, p.Description,
|
||||
))
|
||||
}
|
||||
sb.WriteString(`</div></div>`)
|
||||
|
||||
h.render(w, "page.html", TD{
|
||||
Title: sec.Title + " — Arcline Docs",
|
||||
Canonical: "https://docs.arcline.it/" + slug + "/",
|
||||
Nav: h.buildNav(slug, ""),
|
||||
Breadcrumbs: []breadcrumb{{Label: sec.Title}},
|
||||
Year: time.Now().Year(),
|
||||
Content: template.HTML(sb.String()),
|
||||
Customer: c, IsAdmin: h.isAdmin(c),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) publicPage(w http.ResponseWriter, r *http.Request) {
|
||||
parts := pathParts(r.URL.Path)
|
||||
if len(parts) != 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
secSlug, pageSlug := parts[0], parts[1]
|
||||
|
||||
var sec *publicSection
|
||||
for _, s := range h.sections {
|
||||
if s.Slug == secSlug {
|
||||
sec = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if sec == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var pg *publicPage
|
||||
var idx int
|
||||
for i, p := range sec.Pages {
|
||||
if p.Slug == pageSlug {
|
||||
pg, idx = p, i
|
||||
break
|
||||
}
|
||||
}
|
||||
if pg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := renderMarkdown(pg.RawContent)
|
||||
if err != nil {
|
||||
http.Error(w, "render error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var prev, next *pageLink
|
||||
if idx > 0 {
|
||||
prev = &pageLink{sec.Pages[idx-1].Title, sec.Pages[idx-1].URL}
|
||||
}
|
||||
if idx < len(sec.Pages)-1 {
|
||||
next = &pageLink{sec.Pages[idx+1].Title, sec.Pages[idx+1].URL}
|
||||
}
|
||||
|
||||
c, _ := h.currentCustomer(r)
|
||||
h.render(w, "page.html", TD{
|
||||
Title: pg.Title + " — Arcline Docs", Description: pg.Description,
|
||||
Canonical: "https://docs.arcline.it/" + secSlug + "/" + pageSlug + "/",
|
||||
Nav: h.buildNav(secSlug, pageSlug),
|
||||
Breadcrumbs: []breadcrumb{
|
||||
{Label: sec.Title, URL: "/" + secSlug + "/"},
|
||||
{Label: pg.Title},
|
||||
},
|
||||
Prev: prev, Next: next,
|
||||
Year: time.Now().Year(),
|
||||
Content: template.HTML(`<div class="prose">`) + content + `</div>`,
|
||||
Customer: c, IsAdmin: h.isAdmin(c),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Client handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) clientIndex(w http.ResponseWriter, r *http.Request) {
|
||||
c, sub := h.currentCustomer(r)
|
||||
pages, err := h.st.ListVisiblePages(c, sub)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, "client_index.html", TD{
|
||||
Year: time.Now().Year(), Customer: c, IsAdmin: h.isAdmin(c), Pages: pages,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) clientPage(w http.ResponseWriter, r *http.Request) {
|
||||
c, sub := h.currentCustomer(r)
|
||||
pg, err := h.st.GetPageBySlug(r.PathValue("slug"))
|
||||
if err != nil || pg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !store.CanSee(c, sub, pg.Visibility) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
content, err := renderMarkdown([]byte(pg.Content))
|
||||
if err != nil {
|
||||
http.Error(w, "render error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, "client_page.html", TD{
|
||||
Year: time.Now().Year(), Customer: c, IsAdmin: h.isAdmin(c),
|
||||
Page: pg,
|
||||
Content: template.HTML(`<div class="prose">`) + content + `</div>`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) adminPages(w http.ResponseWriter, r *http.Request) {
|
||||
c, _ := h.currentCustomer(r)
|
||||
pages, err := h.st.ListPages()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(w, "admin_pages.html", TD{
|
||||
Year: time.Now().Year(), Customer: c, IsAdmin: true, Pages: pages,
|
||||
CSRFToken: csrfGet(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) adminNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
c, _ := h.currentCustomer(r)
|
||||
customers, _ := h.st.ListCustomers()
|
||||
h.render(w, "admin_edit.html", TD{
|
||||
Year: time.Now().Year(), Customer: c, IsAdmin: true,
|
||||
PlanOptions: store.PlanOptions(), Customers: customers,
|
||||
CSRFToken: csrfGet(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) adminCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil || !csrfCheck(r) {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pg := pageFromForm(r, 0)
|
||||
if pg.Title == "" || pg.Slug == "" {
|
||||
h.adminEditError(w, r, pg, "Title and slug are required.")
|
||||
return
|
||||
}
|
||||
if _, err := h.st.CreatePage(pg); err != nil {
|
||||
msg := "Could not save page."
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
msg = "A page with that slug already exists."
|
||||
}
|
||||
h.adminEditError(w, r, pg, msg)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
pg, err := h.st.GetPageByID(id)
|
||||
if err != nil || pg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
c, _ := h.currentCustomer(r)
|
||||
customers, _ := h.st.ListCustomers()
|
||||
h.render(w, "admin_edit.html", TD{
|
||||
Year: time.Now().Year(), Customer: c, IsAdmin: true, Page: pg,
|
||||
PlanOptions: store.PlanOptions(), Customers: customers,
|
||||
CSRFToken: csrfGet(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) adminUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil || !csrfCheck(r) {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
pg := pageFromForm(r, id)
|
||||
if pg.Title == "" || pg.Slug == "" {
|
||||
h.adminEditError(w, r, pg, "Title and slug are required.")
|
||||
return
|
||||
}
|
||||
if err := h.st.UpdatePage(pg); err != nil {
|
||||
msg := "Could not update page."
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
msg = "A page with that slug already exists."
|
||||
}
|
||||
h.adminEditError(w, r, pg, msg)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil || !csrfCheck(r) {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
_ = h.st.DeletePage(id)
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminEditError(w http.ResponseWriter, r *http.Request, pg *store.Page, msg string) {
|
||||
c, _ := h.currentCustomer(r)
|
||||
customers, _ := h.st.ListCustomers()
|
||||
h.render(w, "admin_edit.html", TD{
|
||||
Year: time.Now().Year(), Customer: c, IsAdmin: true, Page: pg,
|
||||
PlanOptions: store.PlanOptions(), Customers: customers,
|
||||
Error: msg, CSRFToken: csrfGet(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ── CSRF ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func csrfGet(w http.ResponseWriter, r *http.Request) string {
|
||||
if c, err := r.Cookie("csrf"); err == nil && len(c.Value) == 64 {
|
||||
return c.Value
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "csrf", Value: token, Path: "/",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
func csrfCheck(r *http.Request) bool {
|
||||
c, err := r.Cookie("csrf")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return c.Value != "" && r.FormValue("csrf_token") == c.Value
|
||||
}
|
||||
|
||||
// ── Form helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func pageFromForm(r *http.Request, id int64) *store.Page {
|
||||
order, _ := strconv.Atoi(r.FormValue("display_order"))
|
||||
var visibility string
|
||||
switch r.FormValue("vis_type") {
|
||||
case "plan":
|
||||
visibility = "plan:" + r.FormValue("vis_plan")
|
||||
case "customer":
|
||||
visibility = "customer:" + r.FormValue("vis_customer")
|
||||
default:
|
||||
visibility = "public"
|
||||
}
|
||||
return &store.Page{
|
||||
ID: id,
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
Section: strings.TrimSpace(r.FormValue("section")),
|
||||
Content: r.FormValue("content"),
|
||||
Visibility: visibility,
|
||||
DisplayOrder: order,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
cfg := configFromEnv()
|
||||
|
||||
st, err := store.New(cfg.BillingDB, cfg.DocsDB)
|
||||
if err != nil {
|
||||
slog.Error("store init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
h, err := newHandler(cfg, st)
|
||||
if err != nil {
|
||||
slog.Error("handler init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Auth
|
||||
mux.HandleFunc("GET /logout", h.logout)
|
||||
|
||||
// Admin (all fixed-prefix, no wildcard subtree conflicts)
|
||||
mux.Handle("GET /admin/", h.requireAdmin(http.HandlerFunc(h.adminPages)))
|
||||
mux.Handle("GET /admin/pages/new", h.requireAdmin(http.HandlerFunc(h.adminNewPage)))
|
||||
mux.Handle("POST /admin/pages", h.requireAdmin(http.HandlerFunc(h.adminCreate)))
|
||||
mux.Handle("GET /admin/pages/{id}/edit", h.requireAdmin(http.HandlerFunc(h.adminEditPage)))
|
||||
mux.Handle("POST /admin/pages/{id}", h.requireAdmin(http.HandlerFunc(h.adminUpdate)))
|
||||
mux.Handle("POST /admin/pages/{id}/delete", h.requireAdmin(http.HandlerFunc(h.adminDelete)))
|
||||
|
||||
// Client docs (fixed prefix /client/, no conflict with admin)
|
||||
mux.Handle("GET /client/", h.requireAuth(http.HandlerFunc(h.clientIndex)))
|
||||
mux.Handle("GET /client/{slug}/", h.requireAuth(http.HandlerFunc(h.clientPage)))
|
||||
|
||||
// Root catch-all: handles home, public doc pages, and static files.
|
||||
// Wildcard subtree patterns like /{section}/{slug}/ conflict with every
|
||||
// other subtree in Go 1.22's mux, so we route public docs manually here.
|
||||
staticServer := http.FileServer(http.Dir(cfg.StaticDir))
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
// Static assets live in the static dir under /css/, /js/, /public/.
|
||||
if strings.HasPrefix(p, "/css/") || strings.HasPrefix(p, "/js/") || strings.HasPrefix(p, "/public/") {
|
||||
staticServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
parts := pathParts(p)
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
h.home(w, r)
|
||||
case 1:
|
||||
h.sectionIndex(w, r)
|
||||
case 2:
|
||||
h.publicPage(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
slog.Info("docs server starting", "addr", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user