Files

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)
}