feat(manifest): public_routes + sitemap manifest capability (ADR 0026)

Plugins declare site-root path claims (exact or prefix) and a sitemap
contribution flag in plugin.mod; the packer validates and stamps them
into PluginManifest. Validation rejects reserved prefixes (/admin,
/api/plugins, /ws), traversal, duplicates, and prefix claims that cover
a reserved prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-12 02:41:31 +08:00
parent 25bcbad081
commit 623ea2e14b
5 changed files with 532 additions and 84 deletions

View File

@ -139,6 +139,34 @@ message PluginManifest {
// than the 10 MB default. Rides the same admin grant as allowed_hosts. // than the 10 MB default. Rides the same admin grant as allowed_hosts.
// Zero means the default. // Zero means the default.
uint32 max_response_mb = 35; uint32 max_response_mb = 35;
// public_routes declares the site-root paths the plugin serves through its
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the host
// mounts at the public site root, dispatching to HOOK_HANDLE_HTTP with the
// full unstripped request path. Like data_dir and allowed_hosts, the source
// of truth is plugin.mod; the packer validates and stamps it. Conflict
// rules are host-side: core routes always win, between plugins the
// first-installed plugin wins, and conflicts are surfaced in the admin UI,
// never silently dropped. Empty means the plugin serves HTTP only under
// /api/plugins/<name>.
repeated PublicRoute public_routes = 36;
// sitemap requests sitemap contribution: when true the host fetches
// entries from the plugin's well-known sitemap endpoint (served by its
// HTTP handler) and merges them into the site sitemap. Requires
// has_http_handler.
bool sitemap = 37;
}
// PublicRoute is one site-root path claim (see PluginManifest.public_routes).
message PublicRoute {
// Absolute site-root path, e.g. "/area" or "/api/directory". Validated by
// the packer: must start with "/", no reserved prefixes ("/admin",
// "/api/plugins", "/ws"), no traversal or empty segments.
string path = 1;
// When true the claim covers every path under path as well (a path-prefix
// mount); when false only the exact path is claimed.
bool prefix = 2;
} }
// Dependency mirrors plugin.Dependency. // Dependency mirrors plugin.Dependency.

View File

@ -135,6 +135,21 @@ type PluginManifest struct {
// than the 10 MB default. Rides the same admin grant as allowed_hosts. // than the 10 MB default. Rides the same admin grant as allowed_hosts.
// Zero means the default. // Zero means the default.
MaxResponseMb uint32 `protobuf:"varint,35,opt,name=max_response_mb,json=maxResponseMb,proto3" json:"max_response_mb,omitempty"` MaxResponseMb uint32 `protobuf:"varint,35,opt,name=max_response_mb,json=maxResponseMb,proto3" json:"max_response_mb,omitempty"`
// public_routes declares the site-root paths the plugin serves through its
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the host
// mounts at the public site root, dispatching to HOOK_HANDLE_HTTP with the
// full unstripped request path. Like data_dir and allowed_hosts, the source
// of truth is plugin.mod; the packer validates and stamps it. Conflict
// rules are host-side: core routes always win, between plugins the
// first-installed plugin wins, and conflicts are surfaced in the admin UI,
// never silently dropped. Empty means the plugin serves HTTP only under
// /api/plugins/<name>.
PublicRoutes []*PublicRoute `protobuf:"bytes,36,rep,name=public_routes,json=publicRoutes,proto3" json:"public_routes,omitempty"`
// sitemap requests sitemap contribution: when true the host fetches
// entries from the plugin's well-known sitemap endpoint (served by its
// HTTP handler) and merges them into the site sitemap. Requires
// has_http_handler.
Sitemap bool `protobuf:"varint,37,opt,name=sitemap,proto3" json:"sitemap,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -414,6 +429,78 @@ func (x *PluginManifest) GetMaxResponseMb() uint32 {
return 0 return 0
} }
func (x *PluginManifest) GetPublicRoutes() []*PublicRoute {
if x != nil {
return x.PublicRoutes
}
return nil
}
func (x *PluginManifest) GetSitemap() bool {
if x != nil {
return x.Sitemap
}
return false
}
// PublicRoute is one site-root path claim (see PluginManifest.public_routes).
type PublicRoute struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Absolute site-root path, e.g. "/area" or "/api/directory". Validated by
// the packer: must start with "/", no reserved prefixes ("/admin",
// "/api/plugins", "/ws"), no traversal or empty segments.
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
// When true the claim covers every path under path as well (a path-prefix
// mount); when false only the exact path is claimed.
Prefix bool `protobuf:"varint,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PublicRoute) Reset() {
*x = PublicRoute{}
mi := &file_v1_manifest_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PublicRoute) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PublicRoute) ProtoMessage() {}
func (x *PublicRoute) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PublicRoute.ProtoReflect.Descriptor instead.
func (*PublicRoute) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{1}
}
func (x *PublicRoute) GetPath() string {
if x != nil {
return x.Path
}
return ""
}
func (x *PublicRoute) GetPrefix() bool {
if x != nil {
return x.Prefix
}
return false
}
// Dependency mirrors plugin.Dependency. // Dependency mirrors plugin.Dependency.
type Dependency struct { type Dependency struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -426,7 +513,7 @@ type Dependency struct {
func (x *Dependency) Reset() { func (x *Dependency) Reset() {
*x = Dependency{} *x = Dependency{}
mi := &file_v1_manifest_proto_msgTypes[1] mi := &file_v1_manifest_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -438,7 +525,7 @@ func (x *Dependency) String() string {
func (*Dependency) ProtoMessage() {} func (*Dependency) ProtoMessage() {}
func (x *Dependency) ProtoReflect() protoreflect.Message { func (x *Dependency) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[1] mi := &file_v1_manifest_proto_msgTypes[2]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -451,7 +538,7 @@ func (x *Dependency) ProtoReflect() protoreflect.Message {
// Deprecated: Use Dependency.ProtoReflect.Descriptor instead. // Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
func (*Dependency) Descriptor() ([]byte, []int) { func (*Dependency) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{1} return file_v1_manifest_proto_rawDescGZIP(), []int{2}
} }
func (x *Dependency) GetPlugin() string { func (x *Dependency) GetPlugin() string {
@ -494,7 +581,7 @@ type BlockMeta struct {
func (x *BlockMeta) Reset() { func (x *BlockMeta) Reset() {
*x = BlockMeta{} *x = BlockMeta{}
mi := &file_v1_manifest_proto_msgTypes[2] mi := &file_v1_manifest_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -506,7 +593,7 @@ func (x *BlockMeta) String() string {
func (*BlockMeta) ProtoMessage() {} func (*BlockMeta) ProtoMessage() {}
func (x *BlockMeta) ProtoReflect() protoreflect.Message { func (x *BlockMeta) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[2] mi := &file_v1_manifest_proto_msgTypes[3]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -519,7 +606,7 @@ func (x *BlockMeta) ProtoReflect() protoreflect.Message {
// Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead. // Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead.
func (*BlockMeta) Descriptor() ([]byte, []int) { func (*BlockMeta) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{2} return file_v1_manifest_proto_rawDescGZIP(), []int{3}
} }
func (x *BlockMeta) GetKey() string { func (x *BlockMeta) GetKey() string {
@ -585,7 +672,7 @@ type BlockTemplateOverride struct {
func (x *BlockTemplateOverride) Reset() { func (x *BlockTemplateOverride) Reset() {
*x = BlockTemplateOverride{} *x = BlockTemplateOverride{}
mi := &file_v1_manifest_proto_msgTypes[3] mi := &file_v1_manifest_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -597,7 +684,7 @@ func (x *BlockTemplateOverride) String() string {
func (*BlockTemplateOverride) ProtoMessage() {} func (*BlockTemplateOverride) ProtoMessage() {}
func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message { func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[3] mi := &file_v1_manifest_proto_msgTypes[4]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -610,7 +697,7 @@ func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
// Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead. // Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead.
func (*BlockTemplateOverride) Descriptor() ([]byte, []int) { func (*BlockTemplateOverride) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{3} return file_v1_manifest_proto_rawDescGZIP(), []int{4}
} }
func (x *BlockTemplateOverride) GetTemplateKey() string { func (x *BlockTemplateOverride) GetTemplateKey() string {
@ -646,7 +733,7 @@ type SystemTemplateMeta struct {
func (x *SystemTemplateMeta) Reset() { func (x *SystemTemplateMeta) Reset() {
*x = SystemTemplateMeta{} *x = SystemTemplateMeta{}
mi := &file_v1_manifest_proto_msgTypes[4] mi := &file_v1_manifest_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -658,7 +745,7 @@ func (x *SystemTemplateMeta) String() string {
func (*SystemTemplateMeta) ProtoMessage() {} func (*SystemTemplateMeta) ProtoMessage() {}
func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message { func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[4] mi := &file_v1_manifest_proto_msgTypes[5]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -671,7 +758,7 @@ func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
// Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead. // Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead.
func (*SystemTemplateMeta) Descriptor() ([]byte, []int) { func (*SystemTemplateMeta) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{4} return file_v1_manifest_proto_rawDescGZIP(), []int{5}
} }
func (x *SystemTemplateMeta) GetKey() string { func (x *SystemTemplateMeta) GetKey() string {
@ -710,7 +797,7 @@ type PageTemplateMeta struct {
func (x *PageTemplateMeta) Reset() { func (x *PageTemplateMeta) Reset() {
*x = PageTemplateMeta{} *x = PageTemplateMeta{}
mi := &file_v1_manifest_proto_msgTypes[5] mi := &file_v1_manifest_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -722,7 +809,7 @@ func (x *PageTemplateMeta) String() string {
func (*PageTemplateMeta) ProtoMessage() {} func (*PageTemplateMeta) ProtoMessage() {}
func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message { func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[5] mi := &file_v1_manifest_proto_msgTypes[6]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -735,7 +822,7 @@ func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
// Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead. // Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead.
func (*PageTemplateMeta) Descriptor() ([]byte, []int) { func (*PageTemplateMeta) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{5} return file_v1_manifest_proto_rawDescGZIP(), []int{6}
} }
func (x *PageTemplateMeta) GetSystemKey() string { func (x *PageTemplateMeta) GetSystemKey() string {
@ -786,7 +873,7 @@ type AdminPage struct {
func (x *AdminPage) Reset() { func (x *AdminPage) Reset() {
*x = AdminPage{} *x = AdminPage{}
mi := &file_v1_manifest_proto_msgTypes[6] mi := &file_v1_manifest_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -798,7 +885,7 @@ func (x *AdminPage) String() string {
func (*AdminPage) ProtoMessage() {} func (*AdminPage) ProtoMessage() {}
func (x *AdminPage) ProtoReflect() protoreflect.Message { func (x *AdminPage) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[6] mi := &file_v1_manifest_proto_msgTypes[7]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -811,7 +898,7 @@ func (x *AdminPage) ProtoReflect() protoreflect.Message {
// Deprecated: Use AdminPage.ProtoReflect.Descriptor instead. // Deprecated: Use AdminPage.ProtoReflect.Descriptor instead.
func (*AdminPage) Descriptor() ([]byte, []int) { func (*AdminPage) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{6} return file_v1_manifest_proto_rawDescGZIP(), []int{7}
} }
func (x *AdminPage) GetKey() string { func (x *AdminPage) GetKey() string {
@ -856,7 +943,7 @@ type AiAction struct {
func (x *AiAction) Reset() { func (x *AiAction) Reset() {
*x = AiAction{} *x = AiAction{}
mi := &file_v1_manifest_proto_msgTypes[7] mi := &file_v1_manifest_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -868,7 +955,7 @@ func (x *AiAction) String() string {
func (*AiAction) ProtoMessage() {} func (*AiAction) ProtoMessage() {}
func (x *AiAction) ProtoReflect() protoreflect.Message { func (x *AiAction) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[7] mi := &file_v1_manifest_proto_msgTypes[8]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -881,7 +968,7 @@ func (x *AiAction) ProtoReflect() protoreflect.Message {
// Deprecated: Use AiAction.ProtoReflect.Descriptor instead. // Deprecated: Use AiAction.ProtoReflect.Descriptor instead.
func (*AiAction) Descriptor() ([]byte, []int) { func (*AiAction) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{7} return file_v1_manifest_proto_rawDescGZIP(), []int{8}
} }
func (x *AiAction) GetKey() string { func (x *AiAction) GetKey() string {
@ -931,7 +1018,7 @@ type CssManifest struct {
func (x *CssManifest) Reset() { func (x *CssManifest) Reset() {
*x = CssManifest{} *x = CssManifest{}
mi := &file_v1_manifest_proto_msgTypes[8] mi := &file_v1_manifest_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -943,7 +1030,7 @@ func (x *CssManifest) String() string {
func (*CssManifest) ProtoMessage() {} func (*CssManifest) ProtoMessage() {}
func (x *CssManifest) ProtoReflect() protoreflect.Message { func (x *CssManifest) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[8] mi := &file_v1_manifest_proto_msgTypes[9]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -956,7 +1043,7 @@ func (x *CssManifest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CssManifest.ProtoReflect.Descriptor instead. // Deprecated: Use CssManifest.ProtoReflect.Descriptor instead.
func (*CssManifest) Descriptor() ([]byte, []int) { func (*CssManifest) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{8} return file_v1_manifest_proto_rawDescGZIP(), []int{9}
} }
func (x *CssManifest) GetNpmPackages() map[string]string { func (x *CssManifest) GetNpmPackages() map[string]string {
@ -997,7 +1084,7 @@ type DirectoryExtensions struct {
func (x *DirectoryExtensions) Reset() { func (x *DirectoryExtensions) Reset() {
*x = DirectoryExtensions{} *x = DirectoryExtensions{}
mi := &file_v1_manifest_proto_msgTypes[9] mi := &file_v1_manifest_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1009,7 +1096,7 @@ func (x *DirectoryExtensions) String() string {
func (*DirectoryExtensions) ProtoMessage() {} func (*DirectoryExtensions) ProtoMessage() {}
func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message { func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[9] mi := &file_v1_manifest_proto_msgTypes[10]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1022,7 +1109,7 @@ func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead. // Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead.
func (*DirectoryExtensions) Descriptor() ([]byte, []int) { func (*DirectoryExtensions) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{9} return file_v1_manifest_proto_rawDescGZIP(), []int{10}
} }
func (x *DirectoryExtensions) GetBooleanFilterFields() []string { func (x *DirectoryExtensions) GetBooleanFilterFields() []string {
@ -1071,7 +1158,7 @@ type BadgeLabel struct {
func (x *BadgeLabel) Reset() { func (x *BadgeLabel) Reset() {
*x = BadgeLabel{} *x = BadgeLabel{}
mi := &file_v1_manifest_proto_msgTypes[10] mi := &file_v1_manifest_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1083,7 +1170,7 @@ func (x *BadgeLabel) String() string {
func (*BadgeLabel) ProtoMessage() {} func (*BadgeLabel) ProtoMessage() {}
func (x *BadgeLabel) ProtoReflect() protoreflect.Message { func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[10] mi := &file_v1_manifest_proto_msgTypes[11]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1096,7 +1183,7 @@ func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
// Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead. // Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead.
func (*BadgeLabel) Descriptor() ([]byte, []int) { func (*BadgeLabel) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{10} return file_v1_manifest_proto_rawDescGZIP(), []int{11}
} }
func (x *BadgeLabel) GetPositive() string { func (x *BadgeLabel) GetPositive() string {
@ -1126,7 +1213,7 @@ type MasterPageDefinition struct {
func (x *MasterPageDefinition) Reset() { func (x *MasterPageDefinition) Reset() {
*x = MasterPageDefinition{} *x = MasterPageDefinition{}
mi := &file_v1_manifest_proto_msgTypes[11] mi := &file_v1_manifest_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1138,7 +1225,7 @@ func (x *MasterPageDefinition) String() string {
func (*MasterPageDefinition) ProtoMessage() {} func (*MasterPageDefinition) ProtoMessage() {}
func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message { func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[11] mi := &file_v1_manifest_proto_msgTypes[12]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1151,7 +1238,7 @@ func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
// Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead. // Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead.
func (*MasterPageDefinition) Descriptor() ([]byte, []int) { func (*MasterPageDefinition) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{11} return file_v1_manifest_proto_rawDescGZIP(), []int{12}
} }
func (x *MasterPageDefinition) GetKey() string { func (x *MasterPageDefinition) GetKey() string {
@ -1198,7 +1285,7 @@ type MasterPageBlock struct {
func (x *MasterPageBlock) Reset() { func (x *MasterPageBlock) Reset() {
*x = MasterPageBlock{} *x = MasterPageBlock{}
mi := &file_v1_manifest_proto_msgTypes[12] mi := &file_v1_manifest_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1210,7 +1297,7 @@ func (x *MasterPageBlock) String() string {
func (*MasterPageBlock) ProtoMessage() {} func (*MasterPageBlock) ProtoMessage() {}
func (x *MasterPageBlock) ProtoReflect() protoreflect.Message { func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[12] mi := &file_v1_manifest_proto_msgTypes[13]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1223,7 +1310,7 @@ func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
// Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead. // Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead.
func (*MasterPageBlock) Descriptor() ([]byte, []int) { func (*MasterPageBlock) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{12} return file_v1_manifest_proto_rawDescGZIP(), []int{13}
} }
func (x *MasterPageBlock) GetBlockKey() string { func (x *MasterPageBlock) GetBlockKey() string {
@ -1280,7 +1367,7 @@ type CoreServiceBinding struct {
func (x *CoreServiceBinding) Reset() { func (x *CoreServiceBinding) Reset() {
*x = CoreServiceBinding{} *x = CoreServiceBinding{}
mi := &file_v1_manifest_proto_msgTypes[13] mi := &file_v1_manifest_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -1292,7 +1379,7 @@ func (x *CoreServiceBinding) String() string {
func (*CoreServiceBinding) ProtoMessage() {} func (*CoreServiceBinding) ProtoMessage() {}
func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message { func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
mi := &file_v1_manifest_proto_msgTypes[13] mi := &file_v1_manifest_proto_msgTypes[14]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1305,7 +1392,7 @@ func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
// Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead. // Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead.
func (*CoreServiceBinding) Descriptor() ([]byte, []int) { func (*CoreServiceBinding) Descriptor() ([]byte, []int) {
return file_v1_manifest_proto_rawDescGZIP(), []int{13} return file_v1_manifest_proto_rawDescGZIP(), []int{14}
} }
func (x *CoreServiceBinding) GetServiceName() string { func (x *CoreServiceBinding) GetServiceName() string {
@ -1326,7 +1413,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
const file_v1_manifest_proto_rawDesc = "" + const file_v1_manifest_proto_rawDesc = "" +
"\n" + "\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\xdb\r\n" + "\x11v1/manifest.proto\x12\x06abi.v1\"\xaf\x0e\n" +
"\x0ePluginManifest\x12\x1f\n" + "\x0ePluginManifest\x12\x1f\n" +
"\vabi_version\x18\x01 \x01(\rR\n" + "\vabi_version\x18\x01 \x01(\rR\n" +
"abiVersion\x12\x12\n" + "abiVersion\x12\x12\n" +
@ -1366,10 +1453,15 @@ const file_v1_manifest_proto_rawDesc = "" +
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" + "\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
"\bcodeless\x18! \x01(\bR\bcodeless\x12#\n" + "\bcodeless\x18! \x01(\bR\bcodeless\x12#\n" +
"\rallowed_hosts\x18\" \x03(\tR\fallowedHosts\x12&\n" + "\rallowed_hosts\x18\" \x03(\tR\fallowedHosts\x12&\n" +
"\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x1aB\n" + "\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x128\n" +
"\rpublic_routes\x18$ \x03(\v2\x13.abi.v1.PublicRouteR\fpublicRoutes\x12\x18\n" +
"\asitemap\x18% \x01(\bR\asitemap\x1aB\n" +
"\x14RbacMethodRolesEntry\x12\x10\n" + "\x14RbacMethodRolesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"9\n" +
"\vPublicRoute\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" +
"\x06prefix\x18\x02 \x01(\bR\x06prefix\"a\n" +
"\n" + "\n" +
"Dependency\x12\x16\n" + "Dependency\x12\x16\n" +
"\x06plugin\x18\x01 \x01(\tR\x06plugin\x12\x1f\n" + "\x06plugin\x18\x01 \x01(\tR\x06plugin\x12\x1f\n" +
@ -1463,50 +1555,52 @@ func file_v1_manifest_proto_rawDescGZIP() []byte {
return file_v1_manifest_proto_rawDescData return file_v1_manifest_proto_rawDescData
} }
var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
var file_v1_manifest_proto_goTypes = []any{ var file_v1_manifest_proto_goTypes = []any{
(*PluginManifest)(nil), // 0: abi.v1.PluginManifest (*PluginManifest)(nil), // 0: abi.v1.PluginManifest
(*Dependency)(nil), // 1: abi.v1.Dependency (*PublicRoute)(nil), // 1: abi.v1.PublicRoute
(*BlockMeta)(nil), // 2: abi.v1.BlockMeta (*Dependency)(nil), // 2: abi.v1.Dependency
(*BlockTemplateOverride)(nil), // 3: abi.v1.BlockTemplateOverride (*BlockMeta)(nil), // 3: abi.v1.BlockMeta
(*SystemTemplateMeta)(nil), // 4: abi.v1.SystemTemplateMeta (*BlockTemplateOverride)(nil), // 4: abi.v1.BlockTemplateOverride
(*PageTemplateMeta)(nil), // 5: abi.v1.PageTemplateMeta (*SystemTemplateMeta)(nil), // 5: abi.v1.SystemTemplateMeta
(*AdminPage)(nil), // 6: abi.v1.AdminPage (*PageTemplateMeta)(nil), // 6: abi.v1.PageTemplateMeta
(*AiAction)(nil), // 7: abi.v1.AiAction (*AdminPage)(nil), // 7: abi.v1.AdminPage
(*CssManifest)(nil), // 8: abi.v1.CssManifest (*AiAction)(nil), // 8: abi.v1.AiAction
(*DirectoryExtensions)(nil), // 9: abi.v1.DirectoryExtensions (*CssManifest)(nil), // 9: abi.v1.CssManifest
(*BadgeLabel)(nil), // 10: abi.v1.BadgeLabel (*DirectoryExtensions)(nil), // 10: abi.v1.DirectoryExtensions
(*MasterPageDefinition)(nil), // 11: abi.v1.MasterPageDefinition (*BadgeLabel)(nil), // 11: abi.v1.BadgeLabel
(*MasterPageBlock)(nil), // 12: abi.v1.MasterPageBlock (*MasterPageDefinition)(nil), // 12: abi.v1.MasterPageDefinition
(*CoreServiceBinding)(nil), // 13: abi.v1.CoreServiceBinding (*MasterPageBlock)(nil), // 13: abi.v1.MasterPageBlock
nil, // 14: abi.v1.PluginManifest.RbacMethodRolesEntry (*CoreServiceBinding)(nil), // 14: abi.v1.CoreServiceBinding
nil, // 15: abi.v1.CssManifest.NpmPackagesEntry nil, // 15: abi.v1.PluginManifest.RbacMethodRolesEntry
nil, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry nil, // 16: abi.v1.CssManifest.NpmPackagesEntry
nil, // 17: abi.v1.CoreServiceBinding.MethodRolesEntry nil, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry
nil, // 18: abi.v1.CoreServiceBinding.MethodRolesEntry
} }
var file_v1_manifest_proto_depIdxs = []int32{ var file_v1_manifest_proto_depIdxs = []int32{
1, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency 2, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
2, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta 3, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
3, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride 4, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride
4, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta 5, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta
5, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta 6, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
6, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage 7, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
11, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition 12, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
7, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction 8, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction
14, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry 15, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry
8, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest 9, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest
9, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions 10, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
13, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding 14, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding
15, // 12: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry 1, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute
16, // 13: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry 16, // 13: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry
12, // 14: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock 17, // 14: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
17, // 15: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry 13, // 15: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
10, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel 18, // 16: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
17, // [17:17] is the sub-list for method output_type 11, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
17, // [17:17] is the sub-list for method input_type 18, // [18:18] is the sub-list for method output_type
17, // [17:17] is the sub-list for extension type_name 18, // [18:18] is the sub-list for method input_type
17, // [17:17] is the sub-list for extension extendee 18, // [18:18] is the sub-list for extension type_name
0, // [0:17] is the sub-list for field type_name 18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
} }
func init() { file_v1_manifest_proto_init() } func init() { file_v1_manifest_proto_init() }
@ -1514,14 +1608,14 @@ func file_v1_manifest_proto_init() {
if File_v1_manifest_proto != nil { if File_v1_manifest_proto != nil {
return return
} }
file_v1_manifest_proto_msgTypes[12].OneofWrappers = []any{} file_v1_manifest_proto_msgTypes[13].OneofWrappers = []any{}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 18, NumMessages: 19,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

View File

@ -65,6 +65,28 @@ type ModPlugin struct {
// than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero // than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero
// means the default. // means the default.
MaxResponseMB uint32 `toml:"max_response_mb,omitempty"` MaxResponseMB uint32 `toml:"max_response_mb,omitempty"`
// PublicRoutes declares site-root paths the plugin serves through its
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the
// host mounts at the public site root. First-class for the same reason as
// DataDir and AllowedHosts: the writeMod round-trip drops any key without
// a struct home, and a dropped route claim silently unmounts live SEO
// URLs on the next publish. `ninja plugin build` validates the claims
// (ValidatePublicRoutes) and stamps them into the manifest
// (PluginManifest.public_routes). Empty means the plugin serves HTTP only
// under /api/plugins/<name>.
PublicRoutes []ModPublicRoute `toml:"public_routes,omitempty"`
// Sitemap requests sitemap contribution: when true the host fetches
// entries from the plugin's well-known sitemap endpoint and merges them
// into the site sitemap. Stamped into the manifest alongside
// PublicRoutes; requires an HTTP handler.
Sitemap bool `toml:"sitemap,omitempty"`
}
// ModPublicRoute is one site-root path claim in plugin.mod, e.g.
// { path = "/area", prefix = true }.
type ModPublicRoute struct {
Path string `toml:"path"`
Prefix bool `toml:"prefix,omitempty"`
} }
type ModCompat struct { type ModCompat struct {

90
plugin/public_routes.go Normal file
View File

@ -0,0 +1,90 @@
package plugin
import (
"fmt"
"regexp"
"strings"
)
// MaxPublicRoutes caps the number of site-root claims a single plugin may
// declare. Generous: real plugins claim a handful of SEO prefixes.
const MaxPublicRoutes = 64
// ReservedRoutePrefixes are site-root prefixes plugins may never claim: the
// admin SPA, the namespaced plugin HTTP mount, and the realtime websocket.
// A claim equal to or under one of these is rejected, as is a prefix claim
// that would cover one (e.g. prefix "/api" covers "/api/plugins").
var ReservedRoutePrefixes = []string{"/admin", "/api/plugins", "/ws"}
// routeSegmentRe matches one path segment: URL-safe unreserved characters.
var routeSegmentRe = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
// ValidatePublicRoutes checks a plugin.mod public_routes declaration. It
// returns an error listing every offending entry so authors fix them in one
// pass.
//
// Rules:
// - at most MaxPublicRoutes entries
// - each path is absolute ("/..."), not the site root "/" itself
// - segments are non-empty (no "//", no trailing "/"), never "." or "..",
// and use URL-safe unreserved characters only
// - no claim equal to or under a reserved prefix (ReservedRoutePrefixes),
// and no prefix claim that covers a reserved prefix
// - no duplicate paths
func ValidatePublicRoutes(routes []ModPublicRoute) error {
if len(routes) > MaxPublicRoutes {
return fmt.Errorf("too many public_routes: got %d, max %d", len(routes), MaxPublicRoutes)
}
seen := make(map[string]struct{}, len(routes))
var bad []string
for _, r := range routes {
if reason := checkRoutePath(r); reason != "" {
bad = append(bad, fmt.Sprintf("%q (%s)", r.Path, reason))
continue
}
if _, dup := seen[r.Path]; dup {
bad = append(bad, fmt.Sprintf("%q (duplicate path)", r.Path))
continue
}
seen[r.Path] = struct{}{}
}
if len(bad) > 0 {
return fmt.Errorf("invalid public_routes: %s", strings.Join(bad, "; "))
}
return nil
}
func checkRoutePath(r ModPublicRoute) string {
p := r.Path
if p == "" {
return "empty path"
}
if !strings.HasPrefix(p, "/") {
return "path must be absolute, starting with /"
}
if p == "/" {
return "cannot claim the site root"
}
if strings.HasSuffix(p, "/") {
return "no trailing slash; set prefix = true to claim the subtree"
}
for _, seg := range strings.Split(p[1:], "/") {
switch {
case seg == "":
return "empty path segment"
case seg == "." || seg == "..":
return "path traversal segment"
case !routeSegmentRe.MatchString(seg):
return fmt.Sprintf("segment %q has characters outside [A-Za-z0-9._~-]", seg)
}
}
for _, reserved := range ReservedRoutePrefixes {
if p == reserved || strings.HasPrefix(p, reserved+"/") {
return fmt.Sprintf("reserved prefix %s", reserved)
}
if r.Prefix && strings.HasPrefix(reserved, p+"/") {
return fmt.Sprintf("prefix claim covers reserved prefix %s", reserved)
}
}
return ""
}

View File

@ -0,0 +1,214 @@
package plugin
import (
"fmt"
"strings"
"testing"
)
func TestParseModFull_PublicRoutesAndSitemap(t *testing.T) {
src := []byte(`
[plugin]
name = "perthplaygrounds"
version = "1.0.0"
public_routes = [{ path = "/area", prefix = true }, { path = "/badges" }, { path = "/api/directory", prefix = true }]
sitemap = true
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
routes := m.Plugin.PublicRoutes
if len(routes) != 3 {
t.Fatalf("PublicRoutes len = %d, want 3 (%v)", len(routes), routes)
}
want := []ModPublicRoute{
{Path: "/area", Prefix: true},
{Path: "/badges", Prefix: false},
{Path: "/api/directory", Prefix: true},
}
for i, w := range want {
if routes[i] != w {
t.Errorf("PublicRoutes[%d] = %+v, want %+v", i, routes[i], w)
}
}
if !m.Plugin.Sitemap {
t.Errorf("Sitemap = false, want true")
}
}
func TestParseModFull_PublicRoutesArrayOfTables(t *testing.T) {
src := []byte(`
[plugin]
name = "perthplaygrounds"
version = "1.0.0"
[[plugin.public_routes]]
path = "/area"
prefix = true
[[plugin.public_routes]]
path = "/badges"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
routes := m.Plugin.PublicRoutes
if len(routes) != 2 {
t.Fatalf("PublicRoutes len = %d, want 2 (%v)", len(routes), routes)
}
if routes[0] != (ModPublicRoute{Path: "/area", Prefix: true}) {
t.Errorf("PublicRoutes[0] = %+v", routes[0])
}
if routes[1] != (ModPublicRoute{Path: "/badges"}) {
t.Errorf("PublicRoutes[1] = %+v", routes[1])
}
}
func TestParseModFull_PublicRoutesOmitted(t *testing.T) {
src := []byte(`
[plugin]
name = "plain"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.PublicRoutes != nil {
t.Errorf("PublicRoutes = %v, want nil when omitted", m.Plugin.PublicRoutes)
}
if m.Plugin.Sitemap {
t.Errorf("Sitemap = true, want false (absent key)")
}
}
func TestValidatePublicRoutes_HappyPath(t *testing.T) {
err := ValidatePublicRoutes([]ModPublicRoute{
{Path: "/area", Prefix: true},
{Path: "/badges", Prefix: true},
{Path: "/age", Prefix: true},
{Path: "/api/directory", Prefix: true},
{Path: "/api/semantic-search"},
{Path: "/some_page.html"},
})
if err != nil {
t.Fatalf("ValidatePublicRoutes err: %v", err)
}
}
func TestValidatePublicRoutes_EmptyAndNil(t *testing.T) {
if err := ValidatePublicRoutes(nil); err != nil {
t.Errorf("nil err: %v", err)
}
if err := ValidatePublicRoutes([]ModPublicRoute{}); err != nil {
t.Errorf("empty err: %v", err)
}
}
func TestValidatePublicRoutes_RejectsReservedPrefixes(t *testing.T) {
cases := []ModPublicRoute{
{Path: "/admin"},
{Path: "/admin", Prefix: true},
{Path: "/admin/settings"},
{Path: "/api/plugins"},
{Path: "/api/plugins/foo", Prefix: true},
{Path: "/ws"},
}
for _, c := range cases {
err := ValidatePublicRoutes([]ModPublicRoute{c})
if err == nil {
t.Errorf("ValidatePublicRoutes(%+v) = nil, want reserved-prefix error", c)
continue
}
if !strings.Contains(err.Error(), "reserved prefix") {
t.Errorf("error for %+v does not mention reserved prefix: %v", c, err)
}
}
}
func TestValidatePublicRoutes_RejectsPrefixCoveringReserved(t *testing.T) {
err := ValidatePublicRoutes([]ModPublicRoute{{Path: "/api", Prefix: true}})
if err == nil {
t.Fatal("prefix claim /api should be rejected: it covers /api/plugins")
}
if !strings.Contains(err.Error(), "covers reserved prefix /api/plugins") {
t.Errorf("error does not explain the covered prefix: %v", err)
}
if err := ValidatePublicRoutes([]ModPublicRoute{{Path: "/api"}}); err != nil {
t.Errorf("exact claim /api should be allowed: %v", err)
}
}
func TestValidatePublicRoutes_RejectsMalformedPaths(t *testing.T) {
cases := []struct {
route ModPublicRoute
frag string
}{
{ModPublicRoute{Path: ""}, "empty path"},
{ModPublicRoute{Path: "area"}, "absolute"},
{ModPublicRoute{Path: "/"}, "site root"},
{ModPublicRoute{Path: "/area/"}, "trailing slash"},
{ModPublicRoute{Path: "/area//list"}, "empty path segment"},
{ModPublicRoute{Path: "/area/../admin"}, "traversal"},
{ModPublicRoute{Path: "/area?x=1"}, "characters outside"},
{ModPublicRoute{Path: "/area list"}, "characters outside"},
}
for _, c := range cases {
err := ValidatePublicRoutes([]ModPublicRoute{c.route})
if err == nil {
t.Errorf("ValidatePublicRoutes(%q) = nil, want error", c.route.Path)
continue
}
if !strings.Contains(err.Error(), c.frag) {
t.Errorf("error for %q = %v, want mention of %q", c.route.Path, err, c.frag)
}
}
}
func TestValidatePublicRoutes_RejectsDuplicates(t *testing.T) {
err := ValidatePublicRoutes([]ModPublicRoute{
{Path: "/area"},
{Path: "/area", Prefix: true},
})
if err == nil {
t.Fatal("duplicate path should be rejected")
}
if !strings.Contains(err.Error(), "duplicate path") {
t.Errorf("error does not mention duplicate: %v", err)
}
}
func TestValidatePublicRoutes_ReportsAllOffendersInOnePass(t *testing.T) {
err := ValidatePublicRoutes([]ModPublicRoute{
{Path: "bad"},
{Path: "/admin/x"},
{Path: "/fine"},
})
if err == nil {
t.Fatal("expected error")
}
for _, frag := range []string{`"bad"`, `"/admin/x"`} {
if !strings.Contains(err.Error(), frag) {
t.Errorf("error %q does not mention %s", err.Error(), frag)
}
}
if strings.Contains(err.Error(), `"/fine"`) {
t.Errorf("error should not name the valid route: %v", err)
}
}
func TestValidatePublicRoutes_CapEnforced(t *testing.T) {
routes := make([]ModPublicRoute, MaxPublicRoutes+1)
for i := range routes {
routes[i] = ModPublicRoute{Path: fmt.Sprintf("/r%d", i)}
}
err := ValidatePublicRoutes(routes)
if err == nil {
t.Fatal("expected too-many error")
}
if !strings.Contains(err.Error(), "too many public_routes") {
t.Errorf("error %q does not mention the cap", err.Error())
}
}