Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd4c2f65d | ||
|
|
623ea2e14b | ||
|
|
25bcbad081 |
@ -311,6 +311,79 @@ message DatasourceResult {
|
||||
bytes meta_json = 3;
|
||||
}
|
||||
|
||||
// --- datatables.* (datatables.DataTables) ---
|
||||
|
||||
message DatatablesGetTableByKeyRequest {
|
||||
string table_key = 1;
|
||||
}
|
||||
|
||||
message DatatablesGetTableByKeyResponse {
|
||||
DataTableInfo table = 1;
|
||||
}
|
||||
|
||||
message DatatablesGetRowRequest {
|
||||
string row_id = 1; // UUID
|
||||
}
|
||||
|
||||
message DatatablesGetRowResponse {
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
message DatatablesListRowsRequest {
|
||||
string table_id = 1; // UUID
|
||||
int32 limit = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
|
||||
message DatatablesListRowsResponse {
|
||||
repeated DataTableRowInfo rows = 1;
|
||||
}
|
||||
|
||||
message DatatablesCreateRowRequest {
|
||||
string table_id = 1; // UUID
|
||||
bytes data_json = 2; // JSON object
|
||||
}
|
||||
|
||||
message DatatablesCreateRowResponse {
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
message DatatablesUpdateRowDataRequest {
|
||||
string row_id = 1; // UUID
|
||||
bytes data_json = 2; // JSON object
|
||||
}
|
||||
|
||||
message DatatablesUpdateRowDataResponse {
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
message DatatablesFindRowByFieldRequest {
|
||||
string table_id = 1; // UUID
|
||||
string field = 2;
|
||||
string value = 3;
|
||||
}
|
||||
|
||||
message DatatablesFindRowByFieldResponse {
|
||||
// Unset when no row matches (the guest maps that to nil, nil).
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
// DataTableInfo mirrors datatables.Table.
|
||||
message DataTableInfo {
|
||||
string id = 1; // UUID
|
||||
string table_key = 2;
|
||||
string name = 3;
|
||||
int32 row_count = 4;
|
||||
}
|
||||
|
||||
// DataTableRowInfo mirrors datatables.Row (data column as JSON bytes).
|
||||
message DataTableRowInfo {
|
||||
string id = 1; // UUID
|
||||
string table_id = 2; // UUID
|
||||
bytes data_json = 3; // JSON object
|
||||
int32 sort_order = 4;
|
||||
}
|
||||
|
||||
// --- users.* (auth.PublicUsers) ---
|
||||
|
||||
message UsersGetByUsernameRequest {
|
||||
|
||||
@ -139,6 +139,34 @@ message PluginManifest {
|
||||
// than the 10 MB default. Rides the same admin grant as allowed_hosts.
|
||||
// Zero means the default.
|
||||
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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -135,6 +135,21 @@ type PluginManifest struct {
|
||||
// than the 10 MB default. Rides the same admin grant as allowed_hosts.
|
||||
// Zero means the default.
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -414,6 +429,78 @@ func (x *PluginManifest) GetMaxResponseMb() uint32 {
|
||||
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.
|
||||
type Dependency struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -426,7 +513,7 @@ type Dependency struct {
|
||||
|
||||
func (x *Dependency) Reset() {
|
||||
*x = Dependency{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -438,7 +525,7 @@ func (x *Dependency) String() string {
|
||||
func (*Dependency) ProtoMessage() {}
|
||||
|
||||
func (x *Dependency) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -451,7 +538,7 @@ func (x *Dependency) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -494,7 +581,7 @@ type BlockMeta struct {
|
||||
|
||||
func (x *BlockMeta) Reset() {
|
||||
*x = BlockMeta{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -506,7 +593,7 @@ func (x *BlockMeta) String() string {
|
||||
func (*BlockMeta) ProtoMessage() {}
|
||||
|
||||
func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -519,7 +606,7 @@ func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -585,7 +672,7 @@ type BlockTemplateOverride struct {
|
||||
|
||||
func (x *BlockTemplateOverride) Reset() {
|
||||
*x = BlockTemplateOverride{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -597,7 +684,7 @@ func (x *BlockTemplateOverride) String() string {
|
||||
func (*BlockTemplateOverride) ProtoMessage() {}
|
||||
|
||||
func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -610,7 +697,7 @@ func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -646,7 +733,7 @@ type SystemTemplateMeta struct {
|
||||
|
||||
func (x *SystemTemplateMeta) Reset() {
|
||||
*x = SystemTemplateMeta{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -658,7 +745,7 @@ func (x *SystemTemplateMeta) String() string {
|
||||
func (*SystemTemplateMeta) ProtoMessage() {}
|
||||
|
||||
func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -671,7 +758,7 @@ func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -710,7 +797,7 @@ type PageTemplateMeta struct {
|
||||
|
||||
func (x *PageTemplateMeta) Reset() {
|
||||
*x = PageTemplateMeta{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -722,7 +809,7 @@ func (x *PageTemplateMeta) String() string {
|
||||
func (*PageTemplateMeta) ProtoMessage() {}
|
||||
|
||||
func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -735,7 +822,7 @@ func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -786,7 +873,7 @@ type AdminPage struct {
|
||||
|
||||
func (x *AdminPage) Reset() {
|
||||
*x = AdminPage{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -798,7 +885,7 @@ func (x *AdminPage) String() string {
|
||||
func (*AdminPage) ProtoMessage() {}
|
||||
|
||||
func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -811,7 +898,7 @@ func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AdminPage.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -856,7 +943,7 @@ type AiAction struct {
|
||||
|
||||
func (x *AiAction) Reset() {
|
||||
*x = AiAction{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -868,7 +955,7 @@ func (x *AiAction) String() string {
|
||||
func (*AiAction) ProtoMessage() {}
|
||||
|
||||
func (x *AiAction) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -881,7 +968,7 @@ func (x *AiAction) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AiAction.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -931,7 +1018,7 @@ type CssManifest struct {
|
||||
|
||||
func (x *CssManifest) Reset() {
|
||||
*x = CssManifest{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -943,7 +1030,7 @@ func (x *CssManifest) String() string {
|
||||
func (*CssManifest) ProtoMessage() {}
|
||||
|
||||
func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -956,7 +1043,7 @@ func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use CssManifest.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -997,7 +1084,7 @@ type DirectoryExtensions struct {
|
||||
|
||||
func (x *DirectoryExtensions) Reset() {
|
||||
*x = DirectoryExtensions{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1009,7 +1096,7 @@ func (x *DirectoryExtensions) String() string {
|
||||
func (*DirectoryExtensions) ProtoMessage() {}
|
||||
|
||||
func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1022,7 +1109,7 @@ func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -1071,7 +1158,7 @@ type BadgeLabel struct {
|
||||
|
||||
func (x *BadgeLabel) Reset() {
|
||||
*x = BadgeLabel{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1083,7 +1170,7 @@ func (x *BadgeLabel) String() string {
|
||||
func (*BadgeLabel) ProtoMessage() {}
|
||||
|
||||
func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1096,7 +1183,7 @@ func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -1126,7 +1213,7 @@ type MasterPageDefinition struct {
|
||||
|
||||
func (x *MasterPageDefinition) Reset() {
|
||||
*x = MasterPageDefinition{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1138,7 +1225,7 @@ func (x *MasterPageDefinition) String() string {
|
||||
func (*MasterPageDefinition) ProtoMessage() {}
|
||||
|
||||
func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1151,7 +1238,7 @@ func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -1198,7 +1285,7 @@ type MasterPageBlock struct {
|
||||
|
||||
func (x *MasterPageBlock) Reset() {
|
||||
*x = MasterPageBlock{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1210,7 +1297,7 @@ func (x *MasterPageBlock) String() string {
|
||||
func (*MasterPageBlock) ProtoMessage() {}
|
||||
|
||||
func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1223,7 +1310,7 @@ func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -1280,7 +1367,7 @@ type CoreServiceBinding struct {
|
||||
|
||||
func (x *CoreServiceBinding) Reset() {
|
||||
*x = CoreServiceBinding{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
mi := &file_v1_manifest_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1292,7 +1379,7 @@ func (x *CoreServiceBinding) String() string {
|
||||
func (*CoreServiceBinding) ProtoMessage() {}
|
||||
|
||||
func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
mi := &file_v1_manifest_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1305,7 +1392,7 @@ func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead.
|
||||
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 {
|
||||
@ -1326,7 +1413,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_v1_manifest_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\xdb\r\n" +
|
||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\xaf\x0e\n" +
|
||||
"\x0ePluginManifest\x12\x1f\n" +
|
||||
"\vabi_version\x18\x01 \x01(\rR\n" +
|
||||
"abiVersion\x12\x12\n" +
|
||||
@ -1366,10 +1453,15 @@ const file_v1_manifest_proto_rawDesc = "" +
|
||||
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
|
||||
"\bcodeless\x18! \x01(\bR\bcodeless\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" +
|
||||
"\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" +
|
||||
"Dependency\x12\x16\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
|
||||
}
|
||||
|
||||
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{
|
||||
(*PluginManifest)(nil), // 0: abi.v1.PluginManifest
|
||||
(*Dependency)(nil), // 1: abi.v1.Dependency
|
||||
(*BlockMeta)(nil), // 2: abi.v1.BlockMeta
|
||||
(*BlockTemplateOverride)(nil), // 3: abi.v1.BlockTemplateOverride
|
||||
(*SystemTemplateMeta)(nil), // 4: abi.v1.SystemTemplateMeta
|
||||
(*PageTemplateMeta)(nil), // 5: abi.v1.PageTemplateMeta
|
||||
(*AdminPage)(nil), // 6: abi.v1.AdminPage
|
||||
(*AiAction)(nil), // 7: abi.v1.AiAction
|
||||
(*CssManifest)(nil), // 8: abi.v1.CssManifest
|
||||
(*DirectoryExtensions)(nil), // 9: abi.v1.DirectoryExtensions
|
||||
(*BadgeLabel)(nil), // 10: abi.v1.BadgeLabel
|
||||
(*MasterPageDefinition)(nil), // 11: abi.v1.MasterPageDefinition
|
||||
(*MasterPageBlock)(nil), // 12: abi.v1.MasterPageBlock
|
||||
(*CoreServiceBinding)(nil), // 13: abi.v1.CoreServiceBinding
|
||||
nil, // 14: abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
nil, // 15: abi.v1.CssManifest.NpmPackagesEntry
|
||||
nil, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
nil, // 17: abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
(*PublicRoute)(nil), // 1: abi.v1.PublicRoute
|
||||
(*Dependency)(nil), // 2: abi.v1.Dependency
|
||||
(*BlockMeta)(nil), // 3: abi.v1.BlockMeta
|
||||
(*BlockTemplateOverride)(nil), // 4: abi.v1.BlockTemplateOverride
|
||||
(*SystemTemplateMeta)(nil), // 5: abi.v1.SystemTemplateMeta
|
||||
(*PageTemplateMeta)(nil), // 6: abi.v1.PageTemplateMeta
|
||||
(*AdminPage)(nil), // 7: abi.v1.AdminPage
|
||||
(*AiAction)(nil), // 8: abi.v1.AiAction
|
||||
(*CssManifest)(nil), // 9: abi.v1.CssManifest
|
||||
(*DirectoryExtensions)(nil), // 10: abi.v1.DirectoryExtensions
|
||||
(*BadgeLabel)(nil), // 11: abi.v1.BadgeLabel
|
||||
(*MasterPageDefinition)(nil), // 12: abi.v1.MasterPageDefinition
|
||||
(*MasterPageBlock)(nil), // 13: abi.v1.MasterPageBlock
|
||||
(*CoreServiceBinding)(nil), // 14: abi.v1.CoreServiceBinding
|
||||
nil, // 15: abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
nil, // 16: abi.v1.CssManifest.NpmPackagesEntry
|
||||
nil, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
nil, // 18: abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
}
|
||||
var file_v1_manifest_proto_depIdxs = []int32{
|
||||
1, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
|
||||
2, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
|
||||
3, // 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, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
|
||||
6, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
|
||||
11, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
|
||||
7, // 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
|
||||
8, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest
|
||||
9, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
|
||||
13, // 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
|
||||
16, // 13: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
12, // 14: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
|
||||
17, // 15: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
10, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
|
||||
17, // [17:17] is the sub-list for method output_type
|
||||
17, // [17:17] is the sub-list for method input_type
|
||||
17, // [17:17] is the sub-list for extension type_name
|
||||
17, // [17:17] is the sub-list for extension extendee
|
||||
0, // [0:17] is the sub-list for field type_name
|
||||
2, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
|
||||
3, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
|
||||
4, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride
|
||||
5, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta
|
||||
6, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
|
||||
7, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
|
||||
12, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
|
||||
8, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction
|
||||
15, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
9, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest
|
||||
10, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
|
||||
14, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding
|
||||
1, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute
|
||||
16, // 13: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry
|
||||
17, // 14: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
13, // 15: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
|
||||
18, // 16: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
11, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
|
||||
18, // [18:18] is the sub-list for method output_type
|
||||
18, // [18:18] is the sub-list for method input_type
|
||||
18, // [18:18] is the sub-list for extension 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() }
|
||||
@ -1514,14 +1608,14 @@ func file_v1_manifest_proto_init() {
|
||||
if File_v1_manifest_proto != nil {
|
||||
return
|
||||
}
|
||||
file_v1_manifest_proto_msgTypes[12].OneofWrappers = []any{}
|
||||
file_v1_manifest_proto_msgTypes[13].OneofWrappers = []any{}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 18,
|
||||
NumMessages: 19,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@ -13,6 +13,17 @@ type MediaValue struct {
|
||||
Filename string // Original filename
|
||||
Width int // Image width in pixels
|
||||
Height int // Image height in pixels
|
||||
// Attribution is the stock-source credit (Pexels import), nil for
|
||||
// ordinary uploads. JSON tags mirror the CMS media row's attribution JSONB.
|
||||
Attribution *MediaAttribution
|
||||
}
|
||||
|
||||
// MediaAttribution is the stock-source credit for imported media.
|
||||
type MediaAttribution struct {
|
||||
Source string `json:"source"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Photographer string `json:"photographer"`
|
||||
PhotographerURL string `json:"photographer_url"`
|
||||
}
|
||||
|
||||
// String renders the default <img> tag representation.
|
||||
|
||||
46
datatables/datatables.go
Normal file
46
datatables/datatables.go
Normal file
@ -0,0 +1,46 @@
|
||||
// Package datatables gives plugins typed access to site data tables (the Data
|
||||
// Platform's data_tables/data_table_rows), which the per-plugin Postgres role
|
||||
// deliberately cannot reach with raw SQL. The CMS implements this interface
|
||||
// host-side and wires it into CoreServices; row payloads cross the ABI as JSON.
|
||||
package datatables
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DataTables provides read/write access to site data tables for plugins.
|
||||
type DataTables interface {
|
||||
// GetTableByKey resolves a data table by its stable key (e.g. "playgrounds").
|
||||
GetTableByKey(ctx context.Context, tableKey string) (*Table, error)
|
||||
// GetRow fetches a single row by ID.
|
||||
GetRow(ctx context.Context, rowID uuid.UUID) (*Row, error)
|
||||
// ListRows pages through a table's rows in sort order. limit <= 0 applies
|
||||
// the host default.
|
||||
ListRows(ctx context.Context, tableID uuid.UUID, limit, offset int32) ([]Row, error)
|
||||
// CreateRow appends a row with the given JSON object payload.
|
||||
CreateRow(ctx context.Context, tableID uuid.UUID, dataJSON []byte) (*Row, error)
|
||||
// UpdateRowData replaces a row's JSON payload, preserving its sort order.
|
||||
UpdateRowData(ctx context.Context, rowID uuid.UUID, dataJSON []byte) (*Row, error)
|
||||
// FindRowByField returns the first row whose data->>field equals value, or
|
||||
// (nil, nil) when no row matches.
|
||||
FindRowByField(ctx context.Context, tableID uuid.UUID, field, value string) (*Row, error)
|
||||
}
|
||||
|
||||
// Table is a plugin-facing view of a data table.
|
||||
type Table struct {
|
||||
ID uuid.UUID
|
||||
TableKey string
|
||||
Name string
|
||||
RowCount int32
|
||||
}
|
||||
|
||||
// Row is a plugin-facing view of a data table row. DataJSON is the row's data
|
||||
// column verbatim (a JSON object).
|
||||
type Row struct {
|
||||
ID uuid.UUID
|
||||
TableID uuid.UUID
|
||||
DataJSON []byte
|
||||
SortOrder int32
|
||||
}
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/datasources"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/datatables"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/gating"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/menus"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
||||
@ -27,6 +28,7 @@ type CoreServices struct {
|
||||
Crypto crypto.Crypto
|
||||
Menus menus.Menus
|
||||
Datasources datasources.Datasources
|
||||
DataTables datatables.DataTables
|
||||
PublicUsers auth.PublicUsers
|
||||
Subscriptions subscriptions.Subscriptions
|
||||
|
||||
|
||||
@ -65,6 +65,28 @@ type ModPlugin struct {
|
||||
// than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero
|
||||
// means the default.
|
||||
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 {
|
||||
|
||||
90
plugin/public_routes.go
Normal file
90
plugin/public_routes.go
Normal 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 ""
|
||||
}
|
||||
214
plugin/public_routes_test.go
Normal file
214
plugin/public_routes_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -405,6 +405,90 @@ func TestCapabilityRoundTrip(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- datatables ---
|
||||
{
|
||||
name: "datatables_get_table_by_key", wantMethod: "datatables.get_table_by_key",
|
||||
resp: &abiv1.DatatablesGetTableByKeyResponse{Table: &abiv1.DataTableInfo{
|
||||
Id: idTable.String(), TableKey: "playgrounds", Name: "Playgrounds", RowCount: 678,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.GetTableByKey(ctx, "playgrounds")
|
||||
if err != nil || got.ID != idTable || got.TableKey != "playgrounds" || got.RowCount != 678 {
|
||||
t.Errorf("table = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_get_row", wantMethod: "datatables.get_row",
|
||||
resp: &abiv1.DatatablesGetRowResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Whiteman Park"}`), SortOrder: 3,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.GetRow(ctx, idRow)
|
||||
if err != nil || got.ID != idRow || got.TableID != idTable || got.SortOrder != 3 {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_list_rows", wantMethod: "datatables.list_rows",
|
||||
resp: &abiv1.DatatablesListRowsResponse{Rows: []*abiv1.DataTableRowInfo{
|
||||
{Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"A"}`)},
|
||||
{Id: idItem.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"B"}`), SortOrder: 1},
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.ListRows(ctx, idTable, 100, 0)
|
||||
if err != nil || len(got) != 2 || got[0].ID != idRow || got[1].SortOrder != 1 {
|
||||
t.Errorf("rows = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_create_row", wantMethod: "datatables.create_row",
|
||||
resp: &abiv1.DatatablesCreateRowResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"New"}`),
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.CreateRow(ctx, idTable, []byte(`{"name":"New"}`))
|
||||
if err != nil || got.ID != idRow || string(got.DataJSON) != `{"name":"New"}` {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_update_row_data", wantMethod: "datatables.update_row_data",
|
||||
resp: &abiv1.DatatablesUpdateRowDataResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Updated"}`), SortOrder: 7,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.UpdateRowData(ctx, idRow, []byte(`{"name":"Updated"}`))
|
||||
if err != nil || got.SortOrder != 7 || string(got.DataJSON) != `{"name":"Updated"}` {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_find_row_by_field", wantMethod: "datatables.find_row_by_field",
|
||||
resp: &abiv1.DatatablesFindRowByFieldResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"google_place_id":"abc"}`),
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "abc")
|
||||
if err != nil || got == nil || got.ID != idRow {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_find_row_by_field_miss", wantMethod: "datatables.find_row_by_field",
|
||||
resp: &abiv1.DatatablesFindRowByFieldResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "missing")
|
||||
if err != nil || got != nil {
|
||||
t.Errorf("want nil, nil on miss; row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- users ---
|
||||
{
|
||||
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
||||
|
||||
@ -34,6 +34,7 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
||||
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
||||
Menus: &menusStub{base{family: "menus", call: call}},
|
||||
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
||||
DataTables: &datatablesStub{base{family: "datatables", call: call}},
|
||||
PublicUsers: &usersStub{base{family: "users", call: call}},
|
||||
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
||||
Media: &mediaStub{base{family: "media", call: call}},
|
||||
|
||||
102
plugin/wasmguest/caps/datatables.go
Normal file
102
plugin/wasmguest/caps/datatables.go
Normal file
@ -0,0 +1,102 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/datatables"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// datatablesStub implements datatables.DataTables over datatables.* capability
|
||||
// calls. Row payloads cross as JSON bytes.
|
||||
type datatablesStub struct{ base }
|
||||
|
||||
var _ datatables.DataTables = (*datatablesStub)(nil)
|
||||
|
||||
func (s *datatablesStub) GetTableByKey(ctx context.Context, tableKey string) (*datatables.Table, error) {
|
||||
resp := &abiv1.DatatablesGetTableByKeyResponse{}
|
||||
req := &abiv1.DatatablesGetTableByKeyRequest{TableKey: tableKey}
|
||||
if err := s.invoke(ctx, "get_table_by_key", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableFromProto(resp.GetTable()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) GetRow(ctx context.Context, rowID uuid.UUID) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesGetRowResponse{}
|
||||
req := &abiv1.DatatablesGetRowRequest{RowId: rowID.String()}
|
||||
if err := s.invoke(ctx, "get_row", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) ListRows(ctx context.Context, tableID uuid.UUID, limit, offset int32) ([]datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesListRowsResponse{}
|
||||
req := &abiv1.DatatablesListRowsRequest{TableId: tableID.String(), Limit: limit, Offset: offset}
|
||||
if err := s.invoke(ctx, "list_rows", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]datatables.Row, 0, len(resp.GetRows()))
|
||||
for _, r := range resp.GetRows() {
|
||||
if row := dataTableRowFromProto(r); row != nil {
|
||||
rows = append(rows, *row)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) CreateRow(ctx context.Context, tableID uuid.UUID, dataJSON []byte) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesCreateRowResponse{}
|
||||
req := &abiv1.DatatablesCreateRowRequest{TableId: tableID.String(), DataJson: dataJSON}
|
||||
if err := s.invoke(ctx, "create_row", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) UpdateRowData(ctx context.Context, rowID uuid.UUID, dataJSON []byte) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesUpdateRowDataResponse{}
|
||||
req := &abiv1.DatatablesUpdateRowDataRequest{RowId: rowID.String(), DataJson: dataJSON}
|
||||
if err := s.invoke(ctx, "update_row_data", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) FindRowByField(ctx context.Context, tableID uuid.UUID, field, value string) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesFindRowByFieldResponse{}
|
||||
req := &abiv1.DatatablesFindRowByFieldRequest{TableId: tableID.String(), Field: field, Value: value}
|
||||
if err := s.invoke(ctx, "find_row_by_field", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func dataTableFromProto(t *abiv1.DataTableInfo) *datatables.Table {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
id, _ := uuid.Parse(t.GetId())
|
||||
return &datatables.Table{
|
||||
ID: id,
|
||||
TableKey: t.GetTableKey(),
|
||||
Name: t.GetName(),
|
||||
RowCount: t.GetRowCount(),
|
||||
}
|
||||
}
|
||||
|
||||
func dataTableRowFromProto(r *abiv1.DataTableRowInfo) *datatables.Row {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
id, _ := uuid.Parse(r.GetId())
|
||||
tableID, _ := uuid.Parse(r.GetTableId())
|
||||
return &datatables.Row{
|
||||
ID: id,
|
||||
TableID: tableID,
|
||||
DataJSON: r.GetDataJson(),
|
||||
SortOrder: r.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_create_row_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_create_row_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_create_row_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_create_row_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
\
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idmissing
|
||||
0
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idabc
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
g
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"google_place_id":"abc"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_get_row_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_get_row_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_get_row_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_get_row_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
h
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Whiteman Park"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
playgrounds
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
C
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaaplaygroundsPlaygrounds ¦
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaad
|
||||
5
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_resp.pb
vendored
Normal file
5
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_resp.pb
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
Z
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"A"}
|
||||
\
|
||||
$44444444-4444-4444-4444-444444444444$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"B"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb{"name":"Updated"}
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
b
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Updated"}
|
||||
@ -292,11 +292,87 @@ func renderBlock(ctx context.Context, block map[string]any) string {
|
||||
if alt == "" {
|
||||
alt = caption
|
||||
}
|
||||
sb.WriteString(`<figure class="my-6">`)
|
||||
fmt.Fprintf(&sb, `<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
|
||||
link := ""
|
||||
if l, ok := props["link"].(string); ok {
|
||||
link = l
|
||||
}
|
||||
width := ""
|
||||
if w, ok := props["width"].(string); ok {
|
||||
width = w
|
||||
}
|
||||
align := ""
|
||||
if a, ok := props["align"].(string); ok {
|
||||
align = a
|
||||
}
|
||||
attMode := ""
|
||||
if m, ok := props["attributionMode"].(string); ok {
|
||||
attMode = m
|
||||
}
|
||||
|
||||
var att *blocks.MediaAttribution
|
||||
if attMode == "chip" || attMode == "chipHover" || attMode == "below" {
|
||||
if idStr, ok := props["mediaId"].(string); ok && idStr != "" {
|
||||
if id, err := uuid.Parse(idStr); err == nil {
|
||||
if resolver := blocks.GetMediaResolver(ctx); resolver != nil {
|
||||
if mv := resolver.ResolveMedia(ctx, id); mv != nil {
|
||||
att = mv.Attribution
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chip := ""
|
||||
switch attMode {
|
||||
case "chip":
|
||||
chip = buildAttributionHTML(att, imageChipClass)
|
||||
case "chipHover":
|
||||
chip = buildAttributionHTML(att, imageChipClass+" opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity")
|
||||
}
|
||||
|
||||
figureClass := "my-6"
|
||||
switch width {
|
||||
case "small":
|
||||
figureClass += " max-w-[400px]"
|
||||
case "medium":
|
||||
figureClass += " max-w-[600px]"
|
||||
case "wide":
|
||||
figureClass += " max-w-[800px]"
|
||||
}
|
||||
if width == "small" || width == "medium" || width == "wide" {
|
||||
switch align {
|
||||
case "left":
|
||||
figureClass += " float-left mr-6 mb-4"
|
||||
case "right":
|
||||
figureClass += " float-right ml-6 mb-4"
|
||||
default:
|
||||
figureClass += " mx-auto"
|
||||
}
|
||||
}
|
||||
|
||||
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
|
||||
if link != "" {
|
||||
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
|
||||
}
|
||||
// The positioning wrapper only exists when a chip is rendered, so
|
||||
// legacy and chip-less blocks keep today's exact markup.
|
||||
if chip != "" {
|
||||
wrapClass := "relative overflow-hidden"
|
||||
if attMode == "chipHover" {
|
||||
wrapClass = "group " + wrapClass
|
||||
}
|
||||
img = fmt.Sprintf(`<div class="%s">%s%s</div>`, wrapClass, img, chip)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, `<figure class="%s">`, figureClass)
|
||||
sb.WriteString(img)
|
||||
if caption != "" {
|
||||
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
||||
}
|
||||
if attMode == "below" {
|
||||
if credit := buildAttributionHTML(att, ""); credit != "" {
|
||||
fmt.Fprintf(&sb, `<div class="mt-1 text-xs text-muted-foreground text-center">%s</div>`, credit)
|
||||
}
|
||||
}
|
||||
sb.WriteString("</figure>\n")
|
||||
|
||||
case "video":
|
||||
@ -580,6 +656,43 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// imageChipClass matches the page-builder image block's attribution chip
|
||||
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
|
||||
// blog and page credits identically.
|
||||
const imageChipClass = "absolute bottom-2 right-2 z-10 px-2 py-1 rounded-md bg-background/80 backdrop-blur-sm text-xs text-muted-foreground"
|
||||
|
||||
// buildAttributionHTML renders the escaped "Photo by NAME on SOURCE" credit,
|
||||
// or "" when there is no photographer to credit. The "on SOURCE" suffix is
|
||||
// omitted when no source is set.
|
||||
func buildAttributionHTML(att *blocks.MediaAttribution, class string) string {
|
||||
if att == nil || att.Photographer == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<span`)
|
||||
if class != "" {
|
||||
fmt.Fprintf(&b, ` class="%s"`, html.EscapeString(class))
|
||||
}
|
||||
b.WriteString(`>Photo by `)
|
||||
writeAttributionLink(&b, att.PhotographerURL, att.Photographer)
|
||||
if att.Source != "" {
|
||||
sourceLabel := strings.ToUpper(att.Source[:1]) + att.Source[1:]
|
||||
b.WriteString(` on `)
|
||||
writeAttributionLink(&b, att.SourceURL, sourceLabel)
|
||||
}
|
||||
b.WriteString(`</span>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeAttributionLink(b *strings.Builder, href, label string) {
|
||||
if href != "" && (strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "http://")) {
|
||||
fmt.Fprintf(b, `<a href="%s" target="_blank" rel="noopener noreferrer" class="underline">%s</a>`,
|
||||
html.EscapeString(href), html.EscapeString(label))
|
||||
return
|
||||
}
|
||||
b.WriteString(html.EscapeString(label))
|
||||
}
|
||||
|
||||
// autolinkText escapes plain text for HTML output and wraps any https:// URL
|
||||
// substring in <a href="..." rel="noopener">URL</a>. Bare domains, www. and
|
||||
// http:// URLs are intentionally not auto-linked.
|
||||
|
||||
@ -672,3 +672,111 @@ func TestUnknownBlockType(t *testing.T) {
|
||||
t.Errorf("expected fallback rendering of unknown block: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
type testMediaResolver struct {
|
||||
att *blocks.MediaAttribution
|
||||
}
|
||||
|
||||
func (r testMediaResolver) ResolveMedia(_ context.Context, mediaID uuid.UUID) *blocks.MediaValue {
|
||||
return &blocks.MediaValue{Src: "/media/" + mediaID.String(), Attribution: r.att}
|
||||
}
|
||||
|
||||
func imageBlock(props map[string]any) map[string]any {
|
||||
return map[string]any{"blocks": []any{map[string]any{"type": "image", "props": props}}}
|
||||
}
|
||||
|
||||
// Legacy blocks (url/caption only) must render byte-identical to the old output.
|
||||
func TestImageBlockLegacyUnchanged(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "https://example.com/pic.jpg", "caption": "A pic",
|
||||
}))
|
||||
want := "<figure class=\"my-6\"><img src=\"https://example.com/pic.jpg\" alt=\"A pic\" /><figcaption>A pic</figcaption></figure>\n"
|
||||
if html != want {
|
||||
t.Fatalf("legacy image output changed:\n got: %s\nwant: %s", html, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockAltAndLink(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "alt": "Sunset", "link": "https://example.com/story",
|
||||
}))
|
||||
if !strings.Contains(html, `alt="Sunset"`) {
|
||||
t.Fatalf("alt not rendered: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `<a href="https://example.com/story"><img `) {
|
||||
t.Fatalf("link wrap not rendered: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockWidthAndAlign(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "width": "medium", "align": "right",
|
||||
}))
|
||||
if !strings.Contains(html, `max-w-[600px]`) || !strings.Contains(html, `float-right ml-6 mb-4`) {
|
||||
t.Fatalf("width/align classes missing: %s", html)
|
||||
}
|
||||
|
||||
centered := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "width": "small", "align": "center",
|
||||
}))
|
||||
if !strings.Contains(centered, `max-w-[400px]`) || !strings.Contains(centered, `mx-auto`) {
|
||||
t.Fatalf("centered width classes missing: %s", centered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockAttributionModes(t *testing.T) {
|
||||
mediaID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
att := &blocks.MediaAttribution{
|
||||
Photographer: "Jane Doe",
|
||||
PhotographerURL: "https://www.pexels.com/@janedoe",
|
||||
Source: "pexels",
|
||||
SourceURL: "https://www.pexels.com/photo/123",
|
||||
}
|
||||
ctx := blocks.WithMediaResolver(context.Background(), testMediaResolver{att: att})
|
||||
props := func(mode string) map[string]any {
|
||||
return map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String(), "attributionMode": mode}
|
||||
}
|
||||
|
||||
chip := BlockNoteToHTML(ctx, imageBlock(props("chip")))
|
||||
if !strings.Contains(chip, "Photo by ") || !strings.Contains(chip, "Jane Doe") ||
|
||||
!strings.Contains(chip, "on ") || !strings.Contains(chip, "Pexels") ||
|
||||
!strings.Contains(chip, `absolute bottom-2 right-2`) {
|
||||
t.Fatalf("chip mode wrong: %s", chip)
|
||||
}
|
||||
|
||||
hover := BlockNoteToHTML(ctx, imageBlock(props("chipHover")))
|
||||
if !strings.Contains(hover, `group-hover:opacity-100`) || !strings.Contains(hover, `class="group relative overflow-hidden"`) {
|
||||
t.Fatalf("chipHover mode wrong: %s", hover)
|
||||
}
|
||||
|
||||
below := BlockNoteToHTML(ctx, imageBlock(props("below")))
|
||||
if !strings.Contains(below, `mt-1 text-xs text-muted-foreground text-center`) || !strings.Contains(below, "Jane Doe") {
|
||||
t.Fatalf("below mode wrong: %s", below)
|
||||
}
|
||||
|
||||
off := BlockNoteToHTML(ctx, imageBlock(map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String()}))
|
||||
if strings.Contains(off, "Photo by") {
|
||||
t.Fatalf("default mode must render no credit: %s", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockAttributionDegradesGracefully(t *testing.T) {
|
||||
mediaID := uuid.MustParse("44444444-4444-4444-4444-444444444444")
|
||||
props := imageBlock(map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String(), "attributionMode": "chip"})
|
||||
|
||||
// No resolver in ctx.
|
||||
if html := BlockNoteToHTML(context.Background(), props); strings.Contains(html, "Photo by") {
|
||||
t.Fatalf("no-resolver ctx must render no credit: %s", html)
|
||||
}
|
||||
// Resolver returns media without attribution.
|
||||
ctx := blocks.WithMediaResolver(context.Background(), testMediaResolver{att: nil})
|
||||
if html := BlockNoteToHTML(ctx, props); strings.Contains(html, "Photo by") {
|
||||
t.Fatalf("nil attribution must render no credit: %s", html)
|
||||
}
|
||||
// Photographer-only credit omits the "on SOURCE" suffix.
|
||||
ctx = blocks.WithMediaResolver(context.Background(), testMediaResolver{att: &blocks.MediaAttribution{Photographer: "Jane"}})
|
||||
html := BlockNoteToHTML(ctx, props)
|
||||
if !strings.Contains(html, "Photo by ") || strings.Contains(html, " on ") {
|
||||
t.Fatalf("photographer-only credit wrong: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user