- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
79 lines
2.2 KiB
Go
79 lines
2.2 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
func decodeStrict(raw []byte, dest any) error {
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
|
if err := dec.Decode(dest); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeStrictOrdered is decodeStrict with every nested mapping kept as an
|
|
// ordered yaml.MapSlice, so a file that is decoded, re-marshalled per entry and
|
|
// decoded again keeps its key order at any depth and any indentation.
|
|
func decodeStrictOrdered(raw []byte, dest any) error {
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField(), yaml.UseOrderedMap())
|
|
return dec.Decode(dest)
|
|
}
|
|
|
|
func readAsset(fsys fs.FS, name string) ([]byte, error) {
|
|
name = path.Clean(name)
|
|
if name == "." || strings.HasPrefix(name, "..") || strings.Contains(name, "..") {
|
|
return nil, fmt.Errorf("path escapes the plugin")
|
|
}
|
|
return fs.ReadFile(fsys, name)
|
|
}
|
|
|
|
// assetPath resolves a YAML file reference to a path inside the plugin's
|
|
// AdminFS: a plugin-relative path, ~/plugins/<vendor>/<plugin>/rest, or
|
|
// WinterCMS's $/<vendor>/<plugin>/rest ($/ is the plugins directory). A $/
|
|
// path into another plugin cannot be read from this plugin's tree and is an
|
|
// error.
|
|
func assetPath(pluginID, ref string) (string, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
own := strings.ReplaceAll(pluginID, ".", "/") + "/"
|
|
if rest, ok := strings.CutPrefix(ref, "$/"); ok {
|
|
if !strings.HasPrefix(rest, own) {
|
|
return "", fmt.Errorf("$/ path %s names another plugin", ref)
|
|
}
|
|
ref = strings.TrimPrefix(rest, own)
|
|
}
|
|
ref = strings.TrimPrefix(ref, "~/")
|
|
prefix := "plugins/" + own
|
|
ref = strings.TrimPrefix(ref, prefix)
|
|
ref = path.Clean(ref)
|
|
if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") {
|
|
return "", fmt.Errorf("list path escapes the plugin")
|
|
}
|
|
return ref, nil
|
|
}
|
|
|
|
func identifier(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
|
|
case i > 0 && r >= '0' && r <= '9':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func bootErr(pluginID, controllerID, file string, err error) error {
|
|
return fmt.Errorf("cabana: admin schema %s/%s/%s: %w", pluginID, controllerID, file, err)
|
|
}
|