Compare commits

...

3 Commits
v0.2.5 ... main

Author SHA1 Message Date
Alex Dunmow
6fd4c2f65d feat(datatables): DataTables capability for site data table access
Wasm plugins' isolated Postgres role is deliberately denied on core
tables, which left no sanctioned path to data_tables/data_table_rows.
Adds the datatables.* capability family (get_table_by_key, get_row,
list_rows, create_row, update_row_data, find_row_by_field) following
the datasources pattern: interface package, ABI proto messages, guest
stub, CoreServices wiring, golden round-trip coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:08:14 +08:00
Alex Dunmow
623ea2e14b 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>
2026-07-12 02:41:31 +08:00
Alex Dunmow
25bcbad081 feat(render): image block layout, link, and live media attribution
MediaValue gains an Attribution credit resolved through the existing
MediaResolver context hook. The BlockNote image case renders width and
align presets, alt text, an optional link wrap, and the attribution chip
in chip, chipHover, and below modes with the same themed markup as the
cms page-builder image block. Legacy url-and-caption blocks render
byte-identical to the previous output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:51:18 +08:00
29 changed files with 2293 additions and 495 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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,
},

View File

@ -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
View 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
}

View File

@ -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

View File

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

View File

@ -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",

View File

@ -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}},

View 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(),
}
}

View File

@ -0,0 +1,2 @@
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}

View File

@ -0,0 +1,3 @@
\
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}

View File

@ -0,0 +1,2 @@
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idmissing

View File

@ -0,0 +1,2 @@
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idabc

View File

@ -0,0 +1,3 @@
g
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"google_place_id":"abc"}

View File

@ -0,0 +1,2 @@
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb

View File

@ -0,0 +1,3 @@
h
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Whiteman Park"} 

View File

@ -0,0 +1,2 @@
playgrounds

View File

@ -0,0 +1,3 @@
C
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa playgrounds Playgrounds ¦

View File

@ -0,0 +1,2 @@
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaad

View 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"} 

View File

@ -0,0 +1,2 @@
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb{"name":"Updated"}

View File

@ -0,0 +1,3 @@
b
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Updated"} 

View File

@ -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.

View File

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