214 lines
6.9 KiB
Go
214 lines
6.9 KiB
Go
// Package editor is the hotdog-cms editor: a separate server, never on the host
|
|
// that serves the sites, where people sign in with their git platform and
|
|
// work on sites through it. It holds no accounts and no content of its own:
|
|
// who someone is and what they may do comes from the platform, every change
|
|
// is a commit made with their own token, and sessions are encrypted cookies.
|
|
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/about"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
|
|
)
|
|
|
|
// Config is editor.yaml.
|
|
type Config struct {
|
|
Listen string `yaml:"listen"` // default 127.0.0.1:8190
|
|
PublicURL string `yaml:"public_url"` // how browsers reach the editor; the OAuth redirect is built from it
|
|
Cache string `yaml:"cache"` // where site checkouts live; default under the user cache folder
|
|
Forges []ForgeConfig `yaml:"forges"`
|
|
Sites []SiteConfig `yaml:"sites"`
|
|
Live LiveConfig `yaml:"live"` // the live preview of pages being edited
|
|
// Source is where people using this editor can get its source (the
|
|
// AGPL's section 13). Leave it out when running HotDog CMS as released;
|
|
// set it to your own repository if you've changed the code.
|
|
Source string `yaml:"source"`
|
|
// Links are your own, shown in the editor's footer: a privacy notice,
|
|
// terms, where to get help.
|
|
Links []about.Link `yaml:"links"`
|
|
}
|
|
|
|
// ForgeConfig is an OAuth application on a git platform.
|
|
type ForgeConfig struct {
|
|
Host string `yaml:"host"` // git.example.org, github.com, ...
|
|
Kind forge.Kind `yaml:"kind"` // gitea, forgejo, github, gitlab, bitbucket
|
|
ClientID string `yaml:"client_id"`
|
|
ClientSecretEnv string `yaml:"client_secret_env"` // the variable holding the client secret
|
|
// Base is the platform's web address if it isn't https://<host>.
|
|
Base string `yaml:"base"`
|
|
// Discover also offers the sites found in each signed-in account's
|
|
// repositories (discover.go). Off unless set.
|
|
Discover *DiscoverConfig `yaml:"discover"`
|
|
}
|
|
|
|
// SiteConfig is a site the editor can open.
|
|
type SiteConfig struct {
|
|
ID string `yaml:"id"` // in URLs; default from the name
|
|
Name string `yaml:"name"`
|
|
Repo string `yaml:"repo"` // https clone URL
|
|
Branch string `yaml:"branch"` // the branch the site publishes from; default main
|
|
Subdir string `yaml:"subdir"`
|
|
Preview PreviewConfig `yaml:"preview"`
|
|
// Submissions is where stored form submissions are read: the endpoint's
|
|
// viewer listener, never its public address.
|
|
Submissions SubmissionsConfig `yaml:"submissions"`
|
|
|
|
discovered bool // found in an account's repositories, not listed here
|
|
}
|
|
|
|
// SubmissionsConfig points the editor at an endpoint's submissions viewer.
|
|
type SubmissionsConfig struct {
|
|
URL string `yaml:"url"` // e.g. http://10.0.0.5:8182, on a private network
|
|
TokenEnv string `yaml:"token_env"` // the variable holding the viewer's token
|
|
Site string `yaml:"site"` // the site's host as the endpoint knows it; default from site.yaml's url
|
|
}
|
|
|
|
// PreviewConfig says where `hotdog-cms preview` serves this repository's branches.
|
|
type PreviewConfig struct {
|
|
Domain string `yaml:"domain"` // previews are <branch>.<domain>
|
|
Port string `yaml:"port"`
|
|
Scheme string `yaml:"scheme"`
|
|
}
|
|
|
|
func (p PreviewConfig) url(slug string) string {
|
|
if p.Domain == "" {
|
|
return ""
|
|
}
|
|
scheme := p.Scheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
host := slug + "." + p.Domain
|
|
if p.Port != "" {
|
|
host += ":" + p.Port
|
|
}
|
|
return scheme + "://" + host + "/"
|
|
}
|
|
|
|
// LoadConfig reads editor.yaml.
|
|
func LoadConfig(file string) (*Config, error) {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var c Config
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(&c); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", file, err)
|
|
}
|
|
if c.Listen == "" {
|
|
c.Listen = "127.0.0.1:8190"
|
|
}
|
|
if c.PublicURL == "" {
|
|
c.PublicURL = "http://" + c.Listen
|
|
}
|
|
c.PublicURL = strings.TrimRight(c.PublicURL, "/")
|
|
if c.Source != "" && !about.CheckURL(c.Source) {
|
|
return nil, fmt.Errorf("%s: source %q is not a web address", file, c.Source)
|
|
}
|
|
for _, l := range c.Links {
|
|
if l.Name == "" || !about.CheckURL(l.URL) {
|
|
return nil, fmt.Errorf("%s: links need a name and a web address", file)
|
|
}
|
|
}
|
|
if u, err := url.Parse(c.PublicURL); err != nil || u.Host == "" {
|
|
return nil, fmt.Errorf("%s: public_url %q is not a URL", file, c.PublicURL)
|
|
}
|
|
if c.Cache == "" {
|
|
base, err := os.UserCacheDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.Cache = filepath.Join(base, "hotdog-cms", "editor")
|
|
}
|
|
hosts := map[string]bool{}
|
|
for i, f := range c.Forges {
|
|
switch f.Kind {
|
|
case forge.Gitea, forge.Forgejo, forge.GitHub, forge.GitLab, forge.Bitbucket:
|
|
default:
|
|
return nil, fmt.Errorf("%s: forges[%d]: kind %q is not gitea, forgejo, github, gitlab or bitbucket", file, i, f.Kind)
|
|
}
|
|
if f.Host == "" || f.ClientID == "" || f.ClientSecretEnv == "" {
|
|
return nil, fmt.Errorf("%s: forges[%d]: host, client_id and client_secret_env are required", file, i)
|
|
}
|
|
hosts[strings.ToLower(f.Host)] = true
|
|
}
|
|
ids := map[string]bool{}
|
|
for i := range c.Sites {
|
|
s := &c.Sites[i]
|
|
if s.Branch == "" {
|
|
s.Branch = "main"
|
|
}
|
|
if s.ID == "" {
|
|
s.ID = slug(s.Name)
|
|
}
|
|
if s.ID == "" || ids[s.ID] {
|
|
return nil, fmt.Errorf("%s: sites[%d]: needs a name, and an id no other site has", file, i)
|
|
}
|
|
ids[s.ID] = true
|
|
if s.Subdir != "" && (filepath.IsAbs(s.Subdir) || strings.Contains(s.Subdir, "..")) {
|
|
return nil, fmt.Errorf("%s: sites[%d]: subdir must be relative, inside the repository", file, i)
|
|
}
|
|
u, err := url.Parse(s.Repo)
|
|
if err != nil || u.Scheme != "https" && u.Scheme != "http" {
|
|
return nil, fmt.Errorf("%s: sites[%d]: repo must be an https clone URL", file, i)
|
|
}
|
|
if s.Submissions.URL != "" && s.Submissions.TokenEnv == "" {
|
|
return nil, fmt.Errorf("%s: sites[%d]: submissions needs token_env, the variable holding the viewer's token", file, i)
|
|
}
|
|
if !hosts[strings.ToLower(u.Hostname())] {
|
|
return nil, fmt.Errorf("%s: sites[%d]: no forge configured for %s, so nobody could sign in to it", file, i, u.Hostname())
|
|
}
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func slug(s string) string {
|
|
var b strings.Builder
|
|
dash := false
|
|
for _, r := range strings.ToLower(s) {
|
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
|
b.WriteRune(r)
|
|
dash = false
|
|
} else if b.Len() > 0 && !dash {
|
|
b.WriteByte('-')
|
|
dash = true
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
func (c *Config) site(id string) *SiteConfig {
|
|
for i := range c.Sites {
|
|
if c.Sites[i].ID == id {
|
|
return &c.Sites[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) forge(host string) *ForgeConfig {
|
|
for i := range c.Forges {
|
|
if strings.EqualFold(c.Forges[i].Host, host) {
|
|
return &c.Forges[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *ForgeConfig) base() string {
|
|
if f.Base != "" {
|
|
return strings.TrimRight(f.Base, "/")
|
|
}
|
|
return "https://" + f.Host
|
|
}
|