Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
php:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.2', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- name: PHP lint and unit tests
run: |
find . -name '*.php' -type f -not -path './dist/*' -not -path './.git/*' -print0 | xargs -0 -n1 php -l
php tests/test-import-lib.php
php tests/test-kses.php

python:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ['3.9', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- run: pip install -r requirements.txt
- run: python tests/test_normalize_lib.py
- run: python tests/test_normalize_e2e.py
- run: bash -n bin/build-zip.sh
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to LC Core. Format loosely follows Keep a Changelog; this plugin uses semantic-ish versioning.

## [0.3.0] — 2026-07-17

### Security and correctness

- Restrict the global KSES input allowance to radio/checkbox types.
- Reject ambiguous CSV type signatures, sanitize/limit supplied import keys, count
creates/updates only after successful writes, and require an unambiguous exact
attachment filename match.
- Add an opt-in spreadsheet-safe CSV mode for formula-like cells.

### Performance and delivery

- Stream workbook rows after the bounded header scan instead of loading a whole
sheet into memory.
- Add Python dependency metadata, PHP/Python CI matrices, focused KSES tests,
and filename-bound SHA-256 release checksums.

## [0.2.2] — 2026-07-16

### Added (example per-site config)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A site-agnostic WordPress starter framework for Library Creative sites, deployed alongside `lc-bricks-mcp`. It ships the reusable machinery every LC site needs and **nothing site-specific**: the custom post types, taxonomies, ACF field groups, and import mappings for a given project live in that project's own **config layer**.

- **Version:** 0.1.0
- **Version:** 0.3.0
- **License:** GPL-2.0-or-later
- **Requires PHP:** 7.4
- **Text domain:** `lc-core`
Expand Down Expand Up @@ -76,7 +76,7 @@ The config declares, per output CSV: the source sheet, the column order (with a

```bash
tests/run.sh # php -l + python syntax check + standalone unit tests (no WordPress, no openpyxl)
bin/build-zip.sh # -> dist/lc-core-0.1.0.zip, honoring .distignore
bin/build-zip.sh # -> dist/lc-core-0.3.0.zip, honoring .distignore
```

## What is NOT here (left in the predecessor plugin)
Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Security policy

Report suspected vulnerabilities privately through this repository's GitHub
**Security > Report a vulnerability** flow. Do not open a public issue with
exploit details or production data.

Include the affected LC Core version, reproduction steps, expected impact, and
sanitized sample input when relevant. Remove credentials, personal data, and
client content from all reports.

Only the latest tagged release is supported. Import files should be treated as
untrusted input and reviewed before use on a production site.
11 changes: 11 additions & 0 deletions bin/build-zip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,21 @@ rm -f "$EXCLUDES"

mkdir -p dist
ZIP="$ROOT/dist/lc-core-${VERSION}.zip"
CHECKSUM="${ZIP}.sha256"
rm -f "$ZIP"
( cd "$STAGE" && zip -rq "$ZIP" lc-core )

if command -v shasum >/dev/null 2>&1; then
( cd "$ROOT/dist" && shasum -a 256 "$(basename "$ZIP")" > "$(basename "$CHECKSUM")" )
elif command -v sha256sum >/dev/null 2>&1; then
( cd "$ROOT/dist" && sha256sum "$(basename "$ZIP")" > "$(basename "$CHECKSUM")" )
else
echo "FATAL: shasum or sha256sum is required to produce the release checksum" >&2
exit 1
fi

echo
echo "== Contents =="
unzip -l "$ZIP"
echo "Built: dist/lc-core-${VERSION}.zip"
echo "Checksum: dist/lc-core-${VERSION}.zip.sha256"
3 changes: 2 additions & 1 deletion config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
```php
$config['my_cpt'] = array(
// Header column(s) that uniquely identify this type in a CSV. All must be
// present for the type to be detected. First config entry that matches wins.
// present for the type to be detected. Signatures must be unique; multiple
// matches are rejected as ambiguous before any content is written.
'signature' => array( 'my_signature_column' ),

// Header-title assertion: the file is REJECTED if any of these column titles
Expand Down
38 changes: 27 additions & 11 deletions inc/import-lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,21 @@ function lc_core_assert_headers( $headers, $required ) {
}
endif;

if ( ! function_exists( 'lc_core_detect_type' ) ) :
if ( ! function_exists( 'lc_core_detect_types' ) ) :
/**
* Detect a post type from a CSV header row using per-type signature columns.
*
* $config is the import config (see inc/config.php): keyed by post type, each
* entry may carry a 'signature' => string|string[] of header names that uniquely
* identify that type. The first type whose ALL signature headers are present wins
* (order = config order). Generalizes the predecessor plugin's hard-coded detect_type().
* Return every configured post type whose signature matches the headers.
*
* @param string[] $headers Header cells.
* @param array $config Import config keyed by post type.
* @return string Post type slug, or '' if none matched.
* @return string[] Matching post type slugs.
*/
function lc_core_detect_type( $headers, $config ) {
function lc_core_detect_types( $headers, $config ) {
$have = array();
foreach ( (array) $headers as $h ) {
$have[ (string) $h ] = true;
}

$matches = array();
foreach ( (array) $config as $type => $def ) {
if ( empty( $def['signature'] ) ) {
continue;
Expand All @@ -122,10 +119,29 @@ function lc_core_detect_type( $headers, $config ) {
}
}
if ( $matched ) {
return (string) $type;
$matches[] = (string) $type;
}
}
return '';

return $matches;
}
endif;

if ( ! function_exists( 'lc_core_detect_type' ) ) :
/**
* Detect a post type from a CSV header row using per-type signature columns.
*
* $config is the import config (see inc/config.php): keyed by post type, each
* entry may carry a 'signature' => string|string[] of header names that uniquely
* identify that type. Exactly one type must match; ambiguous signatures fail closed.
*
* @param string[] $headers Header cells.
* @param array $config Import config keyed by post type.
* @return string Post type slug, or '' if none matched.
*/
function lc_core_detect_type( $headers, $config ) {
$matches = lc_core_detect_types( $headers, $config );
return 1 === count( $matches ) ? $matches[0] : '';
}
endif;

Expand Down
37 changes: 25 additions & 12 deletions inc/importer.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ function ( $x ) {
$headers
);

$type = lc_core_detect_type( $headers, $config );
$matching_types = lc_core_detect_types( $headers, $config );
$type = 1 === count( $matching_types ) ? $matching_types[0] : '';
$report['type'] = $type;
if ( ! $type ) {
fclose( $fh );
$report['error'] = 'Could not detect post type from headers. Check the `signature` columns in your import config. Header: ' . implode( ', ', $headers );
$report['error'] = count( $matching_types ) > 1
? 'Ambiguous post type signatures matched: ' . implode( ', ', $matching_types ) . '. Make each signature unique before importing.'
: 'Could not detect post type from headers. Check the `signature` columns in your import config. Header: ' . implode( ', ', $headers );
return $report;
}
if ( ! post_type_exists( $type ) ) {
Expand Down Expand Up @@ -124,9 +127,12 @@ function ( $x ) {
}

// import_key from the CSV, else derived from the TITLE ONLY.
$key = ( isset( $row['import_key'] ) && '' !== $row['import_key'] )
? $row['import_key']
: lc_core_derive_import_key( $title );
if ( isset( $row['import_key'] ) && '' !== $row['import_key'] ) {
$clean_key = sanitize_text_field( $row['import_key'] );
$key = function_exists( 'mb_substr' ) ? mb_substr( $clean_key, 0, 191, 'UTF-8' ) : substr( $clean_key, 0, 191 );
} else {
$key = lc_core_derive_import_key( $title );
}

$status = ( isset( $row['post_status'] ) && in_array( $row['post_status'], array( 'publish', 'draft' ), true ) )
? $row['post_status']
Expand Down Expand Up @@ -193,16 +199,19 @@ function ( $x ) {
if ( $existing ) {
$postarr['ID'] = $existing;
$post_id = wp_update_post( $postarr, true );
$report['updated']++;
} else {
$post_id = wp_insert_post( $postarr, true );
$report['created']++;
}
if ( is_wp_error( $post_id ) ) {
$report['skipped']++;
$report['notes'][] = "Error on '{$title}': " . $post_id->get_error_message();
continue;
}
if ( $existing ) {
$report['updated']++;
} else {
$report['created']++;
}
$claimed[] = (int) $post_id;
if ( $key ) {
update_post_meta( $post_id, $key_meta, $key );
Expand Down Expand Up @@ -357,14 +366,18 @@ function lc_core_ensure_term( $name_or_slug, $tax, &$report, $slug = '' ) {
*/
function lc_core_find_attachment( $filename ) {
global $wpdb;
$name = pathinfo( $filename, PATHINFO_FILENAME );
$id = $wpdb->get_var(
$name = sanitize_file_name( basename( (string) $filename ) );
if ( '' === $name ) {
return 0;
}
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_wp_attached_file' AND meta_value LIKE %s LIMIT 1",
'%' . $wpdb->esc_like( $name ) . '%'
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key='_wp_attached_file' AND (meta_value = %s OR meta_value LIKE %s) ORDER BY post_id ASC LIMIT 2",
$name,
'%/' . $wpdb->esc_like( $name )
)
);
return $id ? (int) $id : 0;
return 1 === count( $ids ) ? (int) $ids[0] : 0;
}

/* -------------------------------------------------------------------------
Expand Down
32 changes: 32 additions & 0 deletions inc/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
}

add_filter( 'wp_kses_allowed_html', 'lc_core_kses_allow_form_controls', 10, 2 );
add_filter( 'pre_kses', 'lc_core_kses_restrict_input_types', 10, 3 );

function lc_core_kses_allow_form_controls( $tags, $context ) {
if ( ! apply_filters( 'lc_core_kses_enable', true ) ) {
Expand Down Expand Up @@ -67,3 +68,34 @@ function lc_core_kses_allow_form_controls( $tags, $context ) {

return (array) apply_filters( 'lc_core_kses_allowed_html', $tags, $context );
}

/**
* Remove input types outside the inert CSS-only contract before KSES runs.
*
* KSES allowlists attribute names, not an enum of allowed attribute values. The
* post-context allowance therefore needs this value-level guard so it cannot be
* reused for password, file, submit, or other interactive controls.
*
* @param string $content Content before KSES filtering.
* @param array $allowed_html Effective allowed tag/attribute map.
* @param array $allowed_protocols Allowed URL protocols (unused).
* @return string Filtered content.
*/
function lc_core_kses_restrict_input_types( $content, $allowed_html, $allowed_protocols = array() ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( ! isset( $allowed_html['input'] ) ) {
return $content;
}

$allowed_types = (array) apply_filters( 'lc_core_kses_input_types', array( 'radio', 'checkbox' ) );
return (string) preg_replace_callback(
'/<input\b[^>]*>/i',
function ( $match ) use ( $allowed_types ) {
if ( 1 !== preg_match( '/\btype\s*=\s*(["\']?)([^\s"\'>]+)\1/i', $match[0], $type_match ) ) {
return '';
}
$type = strtolower( $type_match[2] );
return in_array( $type, $allowed_types, true ) ? $match[0] : '';
},
(string) $content
);
}
4 changes: 2 additions & 2 deletions lc-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* Plugin Name: LC Core
* Description: Site-agnostic starter framework for Library Creative WordPress sites — a config-driven content importer, a KSES allowlist module, and generic Bricks query-var helpers. Per-site CPTs / taxonomies / ACF field groups / import mappings live in a separate config layer (see config/example-config.php). Deploy alongside lc-bricks-mcp.
* Version: 0.2.2
* Version: 0.3.0
* Requires PHP: 7.4
* Author: Library Creative
* License: GPL-2.0-or-later
Expand All @@ -18,7 +18,7 @@
exit;
}

define( 'LC_CORE_VERSION', '0.2.2' );
define( 'LC_CORE_VERSION', '0.3.0' );
define( 'LC_CORE_DIR', plugin_dir_path( __FILE__ ) );
define( 'LC_CORE_URL', plugin_dir_url( __FILE__ ) );

Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Runtime dependencies for tools/normalize_workbook.py.
openpyxl>=3.1,<4
PyYAML>=6,<7
7 changes: 5 additions & 2 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ set -u
cd "$(dirname "$0")/.."

FAIL=0
PYCACHE_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/lc-core-pycache.XXXXXX")
trap 'rm -rf "$PYCACHE_ROOT"' EXIT

echo "=== php -l ==="
while IFS= read -r -d '' f; do
Expand All @@ -16,7 +18,7 @@ done < <(find . -name '*.php' -not -path './dist/*' -not -path './.git/*' -print
echo
echo "=== python3 syntax check ==="
for f in tools/normalize_workbook.py tools/normalize_lib.py tests/test_normalize_lib.py tests/test_normalize_e2e.py; do
if python3 -m py_compile "$f"; then
if PYTHONPYCACHEPREFIX="$PYCACHE_ROOT" python3 -m py_compile "$f"; then
echo "No syntax errors detected in $f"
else
FAIL=1
Expand All @@ -26,13 +28,14 @@ done
echo
echo "=== PHP unit tests (inc/import-lib.php) ==="
php tests/test-import-lib.php || FAIL=1
php tests/test-kses.php || FAIL=1

echo
echo "=== Python unit tests (tools/normalize_lib.py) ==="
python3 tests/test_normalize_lib.py || FAIL=1

echo
echo "=== Python e2e test (tools/normalize_workbook.py; skips without openpyxl) ==="
echo "=== Python e2e test (tools/normalize_workbook.py) ==="
python3 tests/test_normalize_e2e.py || FAIL=1

echo
Expand Down
3 changes: 2 additions & 1 deletion tests/test-import-lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ function check( $label, $actual, $expected ) {
);
check( 'single signature match', lc_core_detect_type( array( 'post_title', 'vendor_category' ), $config ), 'mrfm_vendor' );
check( 'second type', lc_core_detect_type( array( 'post_title', 'event_date' ), $config ), 'mrfm_event' );
check( 'first full match wins (config order)', lc_core_detect_type( array( 'vendor_category', 'event_date' ), $config ), 'mrfm_vendor' );
check( 'ambiguous signatures fail closed', lc_core_detect_type( array( 'vendor_category', 'event_date' ), $config ), '' );
check( 'all ambiguous matches are reported', lc_core_detect_types( array( 'vendor_category', 'event_date' ), $config ), array( 'mrfm_vendor', 'mrfm_event', 'mrfm_both' ) );
check( 'no match -> empty', lc_core_detect_type( array( 'post_title' ), $config ), '' );
check( 'multi-column signature needs ALL', lc_core_detect_type( array( 'event_date' ), array( 'x' => array( 'signature' => array( 'event_date', 'venue' ) ) ) ), '' );
check( 'empty config -> empty', lc_core_detect_type( array( 'post_title' ), array() ), '' );
Expand Down
Loading
Loading