405 lines
12 KiB
Go
405 lines
12 KiB
Go
// Package packs installs extensions the safe way: as files. A pack is a
|
|
// folder, or a git repository, holding sections, partials, layouts and
|
|
// stylesheets, with a pack.yaml saying what it is. Installing copies its
|
|
// files into the site, where they're reviewed, versioned and changed like the
|
|
// site's own, and records it in site.yaml. A pack carries no code: no
|
|
// JavaScript, no SVG (which can hold scripts), and no template with a script
|
|
// tag, an event handler or a javascript: link. Its templates run through the
|
|
// same escaping, checks and Content-Security-Policy as everything else.
|
|
//
|
|
// # pack.yaml
|
|
// name: faq
|
|
// version: 1.0.0
|
|
// title: FAQ
|
|
// description: Questions and answers that open and close.
|
|
// license: MIT
|
|
//
|
|
// Its files keep the site's layout: sections/faq.html, partials/…,
|
|
// layouts/…, assets/…. Assets are installed under assets/packs/<name>/, so
|
|
// two packs never collide; a pack's templates refer to them that way:
|
|
// {{ asset "packs/faq/faq.css" }}.
|
|
package packs
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/markup"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/siteyaml"
|
|
bundled "git.coffeylabs.org/coffey-labs/hotdog-cms/packs"
|
|
)
|
|
|
|
// Manifest is a pack's pack.yaml.
|
|
type Manifest struct {
|
|
Name string `yaml:"name"`
|
|
Version string `yaml:"version"`
|
|
Title string `yaml:"title"`
|
|
Description string `yaml:"description"`
|
|
License string `yaml:"license"`
|
|
Author string `yaml:"author"`
|
|
// Collections are settings a pack's layouts expect (calendar: true,
|
|
// layout: event). Installing adds each to site.yaml's collections:
|
|
// unless the site already configures that collection.
|
|
Collections map[string]yaml.Node `yaml:"collections"`
|
|
// Notes are shown after installing: what to do next.
|
|
Notes string `yaml:"notes"`
|
|
}
|
|
|
|
var (
|
|
nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
|
|
assetExt = map[string]bool{".css": true, ".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true, ".avif": true, ".woff2": true, ".woff": true, ".ttf": true, ".otf": true}
|
|
metaFiles = map[string]bool{"pack.yaml": true, "README.md": true, "LICENSE": true, "LICENSE.md": true, "LICENSE.txt": true, "CHANGELOG.md": true}
|
|
)
|
|
|
|
func dedupe(xs []string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, x := range xs {
|
|
if !seen[x] {
|
|
seen[x] = true
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// File is one file a pack puts in a site.
|
|
type File struct {
|
|
From string // in the pack
|
|
To string // in the site
|
|
}
|
|
|
|
// Check reads a pack and says what's wrong with it, or what it would install.
|
|
func Check(dir string) (*Manifest, []File, error) {
|
|
raw, err := os.ReadFile(filepath.Join(dir, "pack.yaml"))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("not a pack: no pack.yaml")
|
|
}
|
|
var m Manifest
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&m); err != nil {
|
|
return nil, nil, fmt.Errorf("pack.yaml: %w", err)
|
|
}
|
|
if !nameRe.MatchString(m.Name) {
|
|
return nil, nil, fmt.Errorf("pack.yaml: name should be lowercase letters, digits and dashes")
|
|
}
|
|
if m.Version == "" {
|
|
return nil, nil, fmt.Errorf("pack.yaml: version is required")
|
|
}
|
|
for name, node := range m.Collections {
|
|
var cc site.CollectionConfig
|
|
if !nameRe.MatchString(name) {
|
|
return nil, nil, fmt.Errorf("pack.yaml: collection %q should be lowercase letters, digits and dashes", name)
|
|
}
|
|
raw, _ := yaml.Marshal(&node)
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&cc); err != nil {
|
|
return nil, nil, fmt.Errorf("pack.yaml: collections: %s: %w", name, err)
|
|
}
|
|
}
|
|
var files []File
|
|
var problems []string
|
|
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(dir, p)
|
|
rel = filepath.ToSlash(rel)
|
|
if d.IsDir() {
|
|
if rel != "." && strings.HasPrefix(d.Name(), ".") {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
problems = append(problems, rel+": a symbolic link")
|
|
return nil
|
|
}
|
|
if metaFiles[rel] || strings.HasPrefix(d.Name(), ".") {
|
|
return nil
|
|
}
|
|
top, _, _ := strings.Cut(rel, "/")
|
|
ext := strings.ToLower(path.Ext(rel))
|
|
switch {
|
|
case (top == "sections" || top == "partials" || top == "layouts") && ext == ".html" && !strings.Contains(strings.TrimPrefix(rel, top+"/"), "/"):
|
|
src, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found, err := markup.Template(string(src))
|
|
if err != nil {
|
|
problems = append(problems, rel+": "+err.Error())
|
|
return nil
|
|
}
|
|
if len(found) > 0 {
|
|
problems = append(problems, fmt.Sprintf("%s: %s; a pack can't carry code that runs in visitors' browsers", rel, strings.Join(dedupe(found), "; ")))
|
|
return nil
|
|
}
|
|
files = append(files, File{From: rel, To: rel})
|
|
case top == "assets" && assetExt[ext]:
|
|
files = append(files, File{From: rel, To: path.Join("assets", "packs", m.Name, strings.TrimPrefix(rel, "assets/"))})
|
|
case ext == ".js" || ext == ".mjs":
|
|
problems = append(problems, rel+": JavaScript; a pack can't carry code")
|
|
case ext == ".svg":
|
|
problems = append(problems, rel+": SVG, which can carry scripts; use PNG or WebP")
|
|
default:
|
|
problems = append(problems, rel+": not something a pack can install (sections/, partials/, layouts/ as .html; assets/ as stylesheets, pictures or fonts)")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(problems) > 0 {
|
|
sort.Strings(problems)
|
|
return nil, nil, fmt.Errorf("the pack can't be installed:\n %s", strings.Join(problems, "\n "))
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, nil, fmt.Errorf("the pack installs nothing")
|
|
}
|
|
sort.Slice(files, func(i, j int) bool { return files[i].To < files[j].To })
|
|
return &m, files, nil
|
|
}
|
|
|
|
// Bundled says whether name is a pack that comes with HotDog CMS.
|
|
func Bundled(name string) bool {
|
|
if !nameRe.MatchString(name) {
|
|
return false
|
|
}
|
|
_, err := fs.Stat(bundled.FS, name+"/pack.yaml")
|
|
return err == nil
|
|
}
|
|
|
|
// BundledNames lists the packs that come with HotDog CMS.
|
|
func BundledNames() []string {
|
|
var out []string
|
|
entries, _ := fs.ReadDir(bundled.FS, ".")
|
|
for _, e := range entries {
|
|
if e.IsDir() && Bundled(e.Name()) {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CheckSource is Check for anything Add takes: a folder, a bundled pack's
|
|
// name or a git repository.
|
|
func CheckSource(src string) (*Manifest, []File, error) {
|
|
tmp, err := os.MkdirTemp("", "hotdog-cms-pack-")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
dir, _, err := fetch(src, tmp)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return Check(dir)
|
|
}
|
|
|
|
// fetch makes a local copy of a pack: a folder as it is, a pack that comes
|
|
// with HotDog CMS by name, or a git repository (url, or url@ref) cloned at
|
|
// that point. It returns the folder and the commit.
|
|
func fetch(src, tmp string) (string, string, error) {
|
|
if fi, err := os.Stat(src); err == nil && fi.IsDir() {
|
|
return src, "", nil
|
|
}
|
|
if Bundled(src) {
|
|
dir := filepath.Join(tmp, "pack")
|
|
sub, _ := fs.Sub(bundled.FS, src)
|
|
if err := os.CopyFS(dir, sub); err != nil {
|
|
return "", "", err
|
|
}
|
|
return dir, "", nil
|
|
}
|
|
repo, ref := src, ""
|
|
if i := strings.LastIndex(src, "@"); i > strings.Index(src, "://")+3 && !strings.Contains(src[i:], "/") {
|
|
repo, ref = src[:i], src[i+1:]
|
|
}
|
|
if !strings.HasPrefix(repo, "https://") && !strings.HasPrefix(repo, "ssh://") && !strings.HasPrefix(repo, "git@") {
|
|
return "", "", fmt.Errorf("%s is neither a folder nor a git repository address", src)
|
|
}
|
|
dir := filepath.Join(tmp, "pack")
|
|
args := []string{"-c", "core.symlinks=false", "-c", "protocol.ext.allow=never", "-c", "protocol.file.allow=never", "clone", "--quiet", "--depth", "1"}
|
|
if ref != "" {
|
|
if strings.HasPrefix(ref, "-") {
|
|
return "", "", fmt.Errorf("%q isn't a tag or branch", ref)
|
|
}
|
|
args = append(args, "--branch", ref)
|
|
}
|
|
cmd := exec.Command("git", append(args, "--end-of-options", repo, dir)...)
|
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return "", "", fmt.Errorf("git clone %s: %s", src, strings.TrimSpace(string(out)))
|
|
}
|
|
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return dir, strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
// Add installs a pack into a site, or upgrades it. A file already in the
|
|
// site that the pack didn't put there is never overwritten, unless force.
|
|
func Add(siteDir, src string, force bool) (*site.PackRef, error) {
|
|
tmp, err := os.MkdirTemp("", "hotdog-cms-pack-")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
dir, commit, err := fetch(src, tmp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m, files, err := Check(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := site.LoadConfig(siteDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mine := map[string]bool{} // files an older version of this pack put here
|
|
var others []site.PackRef
|
|
for _, p := range cfg.Packs {
|
|
if p.Name == m.Name {
|
|
for _, f := range p.Files {
|
|
mine[f] = true
|
|
}
|
|
continue
|
|
}
|
|
others = append(others, p)
|
|
for _, f := range p.Files {
|
|
for _, nf := range files {
|
|
if nf.To == f {
|
|
return nil, fmt.Errorf("%s is already installed by the %s pack", f, p.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, f := range files {
|
|
if _, err := os.Stat(filepath.Join(siteDir, filepath.FromSlash(f.To))); err == nil && !mine[f.To] && !force {
|
|
return nil, fmt.Errorf("%s is already in the site (not from this pack); move it, or add with -force to replace it", f.To)
|
|
}
|
|
}
|
|
ref := site.PackRef{Name: m.Name, Version: m.Version, Source: src, Commit: commit}
|
|
if fi, err := os.Stat(src); err == nil && fi.IsDir() {
|
|
ref.Source = "" // a local folder's path means nothing to anyone else
|
|
} else if Bundled(src) {
|
|
ref.Source = "hotdog-cms:" + src
|
|
}
|
|
for _, f := range files {
|
|
data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(f.From)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dst := filepath.Join(siteDir, filepath.FromSlash(f.To))
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
ref.Files = append(ref.Files, f.To)
|
|
delete(mine, f.To)
|
|
}
|
|
for f := range mine { // dropped by the new version
|
|
_ = os.Remove(filepath.Join(siteDir, filepath.FromSlash(f)))
|
|
}
|
|
if err := writeRefs(siteDir, append(others, ref)); err != nil {
|
|
return nil, err
|
|
}
|
|
added, err := addCollections(siteDir, cfg, m)
|
|
ref.AddedCollections = added
|
|
ref.Notes = m.Notes
|
|
return &ref, err
|
|
}
|
|
|
|
// addCollections puts the pack's collection settings in site.yaml, for
|
|
// collections the site doesn't configure yet. Removing the pack leaves them:
|
|
// they may have been changed since, and they do nothing without its layouts.
|
|
func addCollections(siteDir string, cfg *site.Config, m *Manifest) ([]string, error) {
|
|
var names []string
|
|
for name := range m.Collections {
|
|
if _, ok := cfg.Collections[name]; !ok {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
return nil, nil
|
|
}
|
|
sort.Strings(names)
|
|
p := filepath.Join(siteDir, "site.yaml")
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
text := string(raw)
|
|
for _, name := range names {
|
|
var v map[string]any
|
|
node := m.Collections[name]
|
|
if err := node.Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
if text, err = siteyaml.AddEntry(text, "collections", name, v); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return names, os.WriteFile(p, []byte(text), 0o644)
|
|
}
|
|
|
|
// Remove takes a pack's files out of a site and forgets it.
|
|
func Remove(siteDir, name string) error {
|
|
cfg, err := site.LoadConfig(siteDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var keep []site.PackRef
|
|
found := false
|
|
for _, p := range cfg.Packs {
|
|
if p.Name != name {
|
|
keep = append(keep, p)
|
|
continue
|
|
}
|
|
found = true
|
|
for _, f := range p.Files {
|
|
_ = os.Remove(filepath.Join(siteDir, filepath.FromSlash(f)))
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("no pack %q in this site", name)
|
|
}
|
|
return writeRefs(siteDir, keep)
|
|
}
|
|
|
|
func writeRefs(siteDir string, refs []site.PackRef) error {
|
|
sort.Slice(refs, func(i, j int) bool { return refs[i].Name < refs[j].Name })
|
|
p := filepath.Join(siteDir, "site.yaml")
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var v any
|
|
if len(refs) > 0 {
|
|
v = refs
|
|
}
|
|
out, err := siteyaml.SetTopLevel(string(raw), "packs", v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(p, []byte(out), 0o644)
|
|
}
|