From 87a59e5a0a162acf35a58bcc16d325dab31f7299 Mon Sep 17 00:00:00 2001 From: Kelin Rescue Hub Date: Sat, 4 Apr 2026 13:11:50 -0400 Subject: [PATCH] Initial import of space_profiles module --- Events.php | 76 +++++ Module.php | 26 ++ README.md | 27 ++ components/TemplateRegistry.php | 26 ++ components/UrlRule.php | 74 +++++ config.php | 19 ++ controllers/ProfileController.php | 25 ++ controllers/PublicController.php | 43 +++ controllers/SettingsController.php | 44 +++ helpers/ProfileAccess.php | 37 +++ helpers/ProfileHtmlSanitizer.php | 101 ++++++ migrations/m260401_130000_initial.php | 104 +++++++ .../m260401_141000_add_template_key.php | 26 ++ migrations/m260401_200000_add_slug.php | 92 ++++++ ...260401_201000_set_rescues_default_home.php | 64 ++++ models/SpaceProfile.php | 33 ++ models/forms/SpaceProfileForm.php | 291 ++++++++++++++++++ module.json | 15 + tests/README.md | 19 ++ tests/codeception.yml | 18 ++ tests/codeception/_bootstrap.php | 26 ++ tests/codeception/_support/UnitTester.php | 7 + tests/codeception/config/unit.php | 3 + tests/codeception/unit.suite.yml | 9 + tests/codeception/unit/ProfileAccessTest.php | 69 +++++ .../unit/ProfileHtmlSanitizerTest.php | 40 +++ .../codeception/unit/SpaceProfileFormTest.php | 52 ++++ tests/codeception/unit/_bootstrap.php | 1 + tests/config/common.php | 6 + tests/config/test.php | 8 + tests/config/unit.php | 3 + views/profile/templates/rescue-center.php | 45 +++ views/profile/view.php | 93 ++++++ views/public/view.php | 10 + views/settings/index.php | 95 ++++++ 35 files changed, 1627 insertions(+) create mode 100644 Events.php create mode 100644 Module.php create mode 100644 README.md create mode 100644 components/TemplateRegistry.php create mode 100644 components/UrlRule.php create mode 100644 config.php create mode 100644 controllers/ProfileController.php create mode 100644 controllers/PublicController.php create mode 100644 controllers/SettingsController.php create mode 100644 helpers/ProfileAccess.php create mode 100644 helpers/ProfileHtmlSanitizer.php create mode 100644 migrations/m260401_130000_initial.php create mode 100644 migrations/m260401_141000_add_template_key.php create mode 100644 migrations/m260401_200000_add_slug.php create mode 100644 migrations/m260401_201000_set_rescues_default_home.php create mode 100644 models/SpaceProfile.php create mode 100644 models/forms/SpaceProfileForm.php create mode 100644 module.json create mode 100644 tests/README.md create mode 100644 tests/codeception.yml create mode 100644 tests/codeception/_bootstrap.php create mode 100644 tests/codeception/_support/UnitTester.php create mode 100644 tests/codeception/config/unit.php create mode 100644 tests/codeception/unit.suite.yml create mode 100644 tests/codeception/unit/ProfileAccessTest.php create mode 100644 tests/codeception/unit/ProfileHtmlSanitizerTest.php create mode 100644 tests/codeception/unit/SpaceProfileFormTest.php create mode 100644 tests/codeception/unit/_bootstrap.php create mode 100644 tests/config/common.php create mode 100644 tests/config/test.php create mode 100644 tests/config/unit.php create mode 100644 views/profile/templates/rescue-center.php create mode 100644 views/profile/view.php create mode 100644 views/public/view.php create mode 100644 views/settings/index.php diff --git a/Events.php b/Events.php new file mode 100644 index 0000000..be14c8d --- /dev/null +++ b/Events.php @@ -0,0 +1,76 @@ +sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('space_profiles')) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('SpaceProfilesModule.base', 'Rescue Profile'), + 'group' => 'modules', + 'url' => $space->createUrl('/space_profiles/profile/view'), + 'icon' => '', + 'sortOrder' => 90, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'space_profiles' + && Yii::$app->controller->id === 'profile' + ), + ]); + } + + public static function onRescueSettingsMenuInit($event): void + { + $space = $event->sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('space_profiles')) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('SpaceProfilesModule.base', 'Space Profile'), + 'url' => $space->createUrl('/space_profiles/settings'), + 'sortOrder' => 200, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'space_profiles' + && Yii::$app->controller->id === 'settings' + ), + ]); + } + + public static function onSpaceAdminMenuInitFallback($event): void + { + $space = $event->sender->space ?? null; + if ($space === null || !$space->moduleManager->isEnabled('space_profiles')) { + return; + } + + if ($space->moduleManager->isEnabled('rescue_foundation') || !$space->isAdmin()) { + return; + } + + $event->sender->addItem([ + 'label' => Yii::t('SpaceProfilesModule.base', 'Space Profile'), + 'group' => 'admin', + 'url' => $space->createUrl('/space_profiles/settings'), + 'icon' => '', + 'sortOrder' => 590, + 'isActive' => ( + Yii::$app->controller + && Yii::$app->controller->module + && Yii::$app->controller->module->id === 'space_profiles' + && Yii::$app->controller->id === 'settings' + ), + ]); + } +} diff --git a/Module.php b/Module.php new file mode 100644 index 0000000..561903e --- /dev/null +++ b/Module.php @@ -0,0 +1,26 @@ +moduleManager->isEnabled('rescue_foundation')) { + return $container->createUrl('/rescue_foundation/settings'); + } + + return $container->createUrl('/space_profiles/settings'); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..524b5bc --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Space Profiles Module + +Template-driven rescue space profile pages with branding assets and controlled HTML regions for HumHub spaces. + +## Requirements + +- HumHub 1.14+ +- `rescue_foundation` module available in the same HumHub instance + +## Installation + +Clone into the HumHub modules path using the folder name `space_profiles`: + +```bash +git clone https://gitea.kelinreij.duckdns.org/humhub-modules/space-profiles.git /var/www/localhost/htdocs/protected/modules/space_profiles +``` + +Enable the module in HumHub (Marketplace / Modules UI), then run migrations: + +```bash +php /var/www/localhost/htdocs/protected/yii migrate/up --include-module-migrations=1 --interactive=0 +``` + +## Notes + +- This module is designed for space-level use. +- If you run in Docker, execute commands inside the HumHub app container. diff --git a/components/TemplateRegistry.php b/components/TemplateRegistry.php new file mode 100644 index 0000000..33473c3 --- /dev/null +++ b/components/TemplateRegistry.php @@ -0,0 +1,26 @@ + Yii::t('SpaceProfilesModule.base', 'Rescue Center (Default)'), + ]; + } + + public static function resolveView(?string $templateKey): string + { + if ($templateKey === self::TEMPLATE_RESCUE_CENTER || empty($templateKey)) { + return 'templates/rescue-center'; + } + + return 'templates/rescue-center'; + } +} diff --git a/components/UrlRule.php b/components/UrlRule.php new file mode 100644 index 0000000..61bbe1b --- /dev/null +++ b/components/UrlRule.php @@ -0,0 +1,74 @@ +getPathInfo(), '/'); + if (strpos($path, self::ROUTE_SEGMENT . '/') !== 0) { + return false; + } + + $parts = explode('/', $path); + if (empty($parts[1])) { + return false; + } + + return [self::PUBLIC_ROUTE, ['slug' => urldecode($parts[1])]]; + } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..c2fe0af --- /dev/null +++ b/config.php @@ -0,0 +1,19 @@ + 'space_profiles', + 'class' => 'humhub\\modules\\space_profiles\\Module', + 'namespace' => 'humhub\\modules\\space_profiles', + 'urlManagerRules' => [ + ['class' => 'humhub\\modules\\space_profiles\\components\\UrlRule'], + ], + 'events' => [ + [Menu::class, Menu::EVENT_INIT, [Events::class, 'onSpaceMenuInit']], + ['humhub\\modules\\rescue_foundation\\widgets\\RescueSettingsMenu', 'rescueSettingsMenuInit', [Events::class, 'onRescueSettingsMenuInit']], + [HeaderControlsMenu::class, HeaderControlsMenu::EVENT_INIT, [Events::class, 'onSpaceAdminMenuInitFallback']], + ], +]; diff --git a/controllers/ProfileController.php b/controllers/ProfileController.php new file mode 100644 index 0000000..ab981bb --- /dev/null +++ b/controllers/ProfileController.php @@ -0,0 +1,25 @@ + $this->contentContainer->contentcontainer_id]); + + return $this->render('view', [ + 'space' => $this->contentContainer, + 'profile' => $profile, + 'isPublicRoute' => false, + ]); + } +} diff --git a/controllers/PublicController.php b/controllers/PublicController.php new file mode 100644 index 0000000..1b6830a --- /dev/null +++ b/controllers/PublicController.php @@ -0,0 +1,43 @@ + $slug]); + if (!$profile instanceof SpaceProfile) { + throw new NotFoundHttpException('Profile not found.'); + } + + $space = Space::findOne(['contentcontainer_id' => $profile->contentcontainer_id]); + if (!$space instanceof Space) { + throw new NotFoundHttpException('Space not found.'); + } + + if (!$space->moduleManager->isEnabled('space_profiles')) { + throw new NotFoundHttpException('Profile not available.'); + } + + if (!ProfileAccess::canView($space)) { + throw new ForbiddenHttpException('You are not allowed to view this profile.'); + } + + $this->view->title = Yii::t('SpaceProfilesModule.base', 'Rescue Profile') . ' - ' . $space->name; + + return $this->render('view', [ + 'space' => $space, + 'profile' => $profile, + 'isPublicRoute' => true, + ]); + } +} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php new file mode 100644 index 0000000..99d7218 --- /dev/null +++ b/controllers/SettingsController.php @@ -0,0 +1,44 @@ + [Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN]]]; + } + + public function actionIndex() + { + $model = new SpaceProfileForm(['contentContainer' => $this->contentContainer]); + + if ($model->load(Yii::$app->request->post())) { + $model->iconFile = UploadedFile::getInstance($model, 'iconFile'); + $model->backgroundImageFile = UploadedFile::getInstance($model, 'backgroundImageFile'); + + if ($model->save()) { + $this->view->saved(); + return $this->refresh(); + } + } + + $subNav = null; + if (class_exists(RescueSettingsMenu::class)) { + $subNav = RescueSettingsMenu::widget(['space' => $this->contentContainer]); + } + + return $this->render('index', [ + 'model' => $model, + 'subNav' => $subNav, + ]); + } +} diff --git a/helpers/ProfileAccess.php b/helpers/ProfileAccess.php new file mode 100644 index 0000000..7e7e954 --- /dev/null +++ b/helpers/ProfileAccess.php @@ -0,0 +1,37 @@ +status === Space::STATUS_DISABLED) { + return false; + } + + $user = $user ?: (!Yii::$app->user->isGuest ? Yii::$app->user->getIdentity() : null); + + if ($user && method_exists($user, 'isSystemAdmin') && $user->isSystemAdmin()) { + return true; + } + + if ($user && method_exists($space, 'isMember') && $space->isMember($user)) { + return true; + } + + if ($space->visibility === Space::VISIBILITY_ALL) { + return true; + } + + if ($space->visibility === Space::VISIBILITY_REGISTERED_ONLY) { + return $user !== null; + } + + return false; + } +} diff --git a/helpers/ProfileHtmlSanitizer.php b/helpers/ProfileHtmlSanitizer.php new file mode 100644 index 0000000..0d7d390 --- /dev/null +++ b/helpers/ProfileHtmlSanitizer.php @@ -0,0 +1,101 @@ +]*>(.*?)#is', $value, $matches)) { + $styleBlocks = $matches[1] ?? []; + } + $value = preg_replace('#]*>.*?#is', '', $value) ?? ''; + + $value = preg_replace('#]*>.*?#is', '', $value) ?? ''; + $value = preg_replace("/\\son[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/i", '', $value) ?? ''; + $value = preg_replace('/javascript\\s*:/i', '', $value) ?? ''; + $value = preg_replace('/expression\\s*\\(/i', '', $value) ?? ''; + + $purified = HtmlPurifier::process($value, [ + 'HTML.Allowed' => 'div,p,br,span,strong,em,b,i,u,ul,ol,li,h1,h2,h3,h4,h5,h6,a[href|title|target],img[src|alt|title|width|height],blockquote,hr,table,thead,tbody,tr,th,td,code,pre', + 'Attr.EnableID' => false, + 'CSS.Trusted' => false, + 'URI.AllowedSchemes' => [ + 'http' => true, + 'https' => true, + 'mailto' => true, + ], + ]); + + $scopedCss = []; + foreach ($styleBlocks as $styleBlock) { + $css = self::scopeCss((string)$styleBlock); + if (trim($css) !== '') { + $scopedCss[] = $css; + } + } + + if (empty($scopedCss)) { + return $purified; + } + + return $purified . "\n"; + } + + private static function scopeCss(string $css): string + { + $css = preg_replace('/@import\\s+[^;]+;/i', '', $css) ?? ''; + $css = preg_replace('/javascript\\s*:/i', '', $css) ?? ''; + $css = preg_replace('/expression\\s*\\(/i', '', $css) ?? ''; + + $chunks = explode('}', $css); + $scoped = []; + + foreach ($chunks as $chunk) { + if (trim($chunk) === '' || strpos($chunk, '{') === false) { + continue; + } + + [$selectors, $body] = explode('{', $chunk, 2); + $selectors = trim($selectors); + if ($selectors === '') { + continue; + } + + if (strpos($selectors, '@') === 0) { + $scoped[] = $selectors . '{' . $body . '}'; + continue; + } + + $selectorParts = array_filter(array_map('trim', explode(',', $selectors))); + $scopedSelectors = []; + + foreach ($selectorParts as $selector) { + $selector = str_replace(':root', '.rescue-profile-scope', $selector); + if (preg_match('/^(html|body)\\b/i', $selector)) { + $selector = preg_replace('/^(html|body)\\b/i', '.rescue-profile-scope', $selector) ?? '.rescue-profile-scope'; + } + + if (strpos($selector, '.rescue-profile-scope') !== 0) { + $selector = '.rescue-profile-scope ' . $selector; + } + + $scopedSelectors[] = $selector; + } + + if (!empty($scopedSelectors)) { + $scoped[] = implode(', ', $scopedSelectors) . '{' . $body . '}'; + } + } + + return implode("\\n", $scoped); + } +} diff --git a/migrations/m260401_130000_initial.php b/migrations/m260401_130000_initial.php new file mode 100644 index 0000000..698c631 --- /dev/null +++ b/migrations/m260401_130000_initial.php @@ -0,0 +1,104 @@ +safeCreateTable('rescue_space_profile', [ + 'id' => $this->primaryKey(), + 'contentcontainer_id' => $this->integer()->notNull(), + 'rescue_name' => $this->string(190)->notNull(), + 'address' => $this->string(255)->notNull(), + 'city' => $this->string(120)->notNull(), + 'state' => $this->string(2)->notNull(), + 'zip' => $this->string(10)->notNull(), + 'email' => $this->string(190)->notNull(), + 'phone' => $this->string(32)->notNull(), + 'animals_we_accept' => $this->text()->notNull(), + 'description' => $this->text()->notNull(), + 'mission_statement' => $this->text()->notNull(), + 'template_key' => $this->string(64)->defaultValue('rescue_center')->notNull(), + 'header_html' => $this->text()->null(), + 'body_html' => $this->text()->null(), + 'footer_html' => $this->text()->null(), + 'icon_path' => $this->string(255)->null(), + 'background_image_path' => $this->string(255)->null(), + 'created_at' => $this->dateTime()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->safeCreateIndex('idx_rescue_space_profile_container', 'rescue_space_profile', 'contentcontainer_id', true); + $this->safeAddForeignKey( + 'fk_rescue_space_profile_container', + 'rescue_space_profile', + 'contentcontainer_id', + 'contentcontainer', + 'id', + 'CASCADE' + ); + + $this->seedFieldMetadata(); + } + + public function safeDown() + { + $this->safeDropTable('rescue_space_profile'); + } + + private function seedFieldMetadata(): void + { + if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) { + return; + } + + $createdAt = date('Y-m-d H:i:s'); + $rows = [ + ['rescue_name', 'Rescue name', 'text', 1, 1, 110], + ['address', 'Address', 'text', 1, 1, 120], + ['city', 'City', 'text', 1, 1, 130], + ['state', 'State', 'text', 1, 1, 140], + ['zip', 'ZIP', 'text', 1, 1, 150], + ['email', 'Email', 'email', 1, 1, 160], + ['phone', 'Phone', 'text', 1, 1, 170], + ['animals_we_accept', 'Animals we accept', 'textarea', 1, 1, 180], + ['description', 'Description', 'textarea', 1, 1, 190], + ['mission_statement', 'Mission statement', 'textarea', 1, 1, 200], + ['template_key', 'Template', 'select', 1, 1, 205], + ['header_html', 'Header HTML', 'html', 0, 1, 210], + ['body_html', 'Body HTML', 'html', 0, 1, 220], + ['footer_html', 'Footer HTML', 'html', 0, 1, 230], + ['icon_path', 'Icon', 'image', 0, 1, 240], + ['background_image_path', 'Background image', 'image', 0, 1, 250], + ]; + + foreach ($rows as $row) { + [$fieldKey, $label, $inputType, $required, $isCore, $sortOrder] = $row; + $exists = (new \yii\db\Query()) + ->from('rescue_field_definition') + ->where(['module_id' => 'space_profiles', 'field_key' => $fieldKey]) + ->exists($this->db); + + if ($exists) { + continue; + } + + $this->insert('rescue_field_definition', [ + 'module_id' => 'space_profiles', + 'group_key' => 'space_profile', + 'field_key' => $fieldKey, + 'label' => $label, + 'input_type' => $inputType, + 'required' => $required, + 'is_core' => $isCore, + 'is_active' => 1, + 'visibility' => 'public', + 'options' => '{}', + 'sort_order' => $sortOrder, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + } +} diff --git a/migrations/m260401_141000_add_template_key.php b/migrations/m260401_141000_add_template_key.php new file mode 100644 index 0000000..6201051 --- /dev/null +++ b/migrations/m260401_141000_add_template_key.php @@ -0,0 +1,26 @@ +db->getSchema()->getTableSchema('rescue_space_profile', true); + if ($schema === null) { + return; + } + + if (!isset($schema->columns['template_key'])) { + $this->addColumn('rescue_space_profile', 'template_key', $this->string(64)->defaultValue('rescue_center')->notNull()); + } + } + + public function safeDown() + { + $schema = $this->db->getSchema()->getTableSchema('rescue_space_profile', true); + if ($schema !== null && isset($schema->columns['template_key'])) { + $this->dropColumn('rescue_space_profile', 'template_key'); + } + } +} diff --git a/migrations/m260401_200000_add_slug.php b/migrations/m260401_200000_add_slug.php new file mode 100644 index 0000000..c5a5541 --- /dev/null +++ b/migrations/m260401_200000_add_slug.php @@ -0,0 +1,92 @@ +db->getSchema()->getTableSchema('rescue_space_profile', true); + if ($schema === null) { + return; + } + + if (!isset($schema->columns['slug'])) { + $this->addColumn('rescue_space_profile', 'slug', $this->string(190)->null()->after('mission_statement')); + } + + $rows = (new \yii\db\Query()) + ->select([ + 'id' => 'rsp.id', + 'rescue_name' => 'rsp.rescue_name', + 'space_name' => 's.name', + ]) + ->from(['rsp' => 'rescue_space_profile']) + ->leftJoin(['cc' => 'contentcontainer'], 'cc.id = rsp.contentcontainer_id') + ->leftJoin(['s' => 'space'], 's.contentcontainer_id = cc.id') + ->orderBy(['rsp.id' => SORT_ASC]) + ->all($this->db); + + foreach ($rows as $row) { + $source = trim((string)($row['space_name'] ?? '')); + if ($source === '') { + $source = trim((string)($row['rescue_name'] ?? '')); + } + + $slug = $this->createUniqueSlug($source, (int)$row['id']); + $this->update('rescue_space_profile', ['slug' => $slug], ['id' => (int)$row['id']]); + } + + $this->alterColumn('rescue_space_profile', 'slug', $this->string(190)->notNull()); + $this->safeCreateIndex('ux_rescue_space_profile_slug', 'rescue_space_profile', 'slug', true); + } + + public function safeDown() + { + $schema = $this->db->getSchema()->getTableSchema('rescue_space_profile', true); + if ($schema === null || !isset($schema->columns['slug'])) { + return; + } + + $this->safeDropIndex('ux_rescue_space_profile_slug', 'rescue_space_profile'); + $this->dropColumn('rescue_space_profile', 'slug'); + } + + private function createUniqueSlug(string $source, int $rowId): string + { + $base = $this->normalizeToSlug($source); + $candidate = $base; + $counter = 1; + + while ($this->slugExists($candidate, $rowId)) { + $candidate = $base . '-' . $counter; + $counter++; + } + + return $candidate; + } + + private function normalizeToSlug(string $value): string + { + $normalized = trim($value); + $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); + if ($ascii !== false) { + $normalized = $ascii; + } + + $slug = strtolower($normalized); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; + $slug = trim($slug, '-'); + + return $slug !== '' ? $slug : 'rescue'; + } + + private function slugExists(string $slug, int $rowId): bool + { + return (new \yii\db\Query()) + ->from('rescue_space_profile') + ->where(['slug' => $slug]) + ->andWhere(['!=', 'id', $rowId]) + ->exists($this->db); + } +} diff --git a/migrations/m260401_201000_set_rescues_default_home.php b/migrations/m260401_201000_set_rescues_default_home.php new file mode 100644 index 0000000..9462139 --- /dev/null +++ b/migrations/m260401_201000_set_rescues_default_home.php @@ -0,0 +1,64 @@ +select(['contentcontainer_id', 'url', 'guid']) + ->from('space') + ->where(['in', 'contentcontainer_id', (new \yii\db\Query()) + ->select('contentcontainer_id') + ->from('rescue_space_profile')]) + ->all($this->db); + + foreach ($rows as $row) { + $spacePath = trim((string)($row['url'] ?? '')); + if ($spacePath === '') { + $spacePath = trim((string)($row['guid'] ?? '')); + } + + if ($spacePath === '') { + continue; + } + + $homeUrl = '/s/' . $spacePath . '/rescues'; + $contentContainerId = (int)$row['contentcontainer_id']; + + $this->upsert('contentcontainer_setting', [ + 'module_id' => 'space', + 'contentcontainer_id' => $contentContainerId, + 'name' => 'indexUrl', + 'value' => $homeUrl, + ], [ + 'value' => $homeUrl, + ]); + + $this->upsert('contentcontainer_setting', [ + 'module_id' => 'space', + 'contentcontainer_id' => $contentContainerId, + 'name' => 'indexGuestUrl', + 'value' => $homeUrl, + ], [ + 'value' => $homeUrl, + ]); + } + } + + public function safeDown() + { + $this->delete('contentcontainer_setting', [ + 'and', + ['module_id' => 'space', 'name' => 'indexUrl'], + ['like', 'value', '/s/%/rescues', false], + ]); + + $this->delete('contentcontainer_setting', [ + 'and', + ['module_id' => 'space', 'name' => 'indexGuestUrl'], + ['like', 'value', '/s/%/rescues', false], + ]); + } +} diff --git a/models/SpaceProfile.php b/models/SpaceProfile.php new file mode 100644 index 0000000..ce095b3 --- /dev/null +++ b/models/SpaceProfile.php @@ -0,0 +1,33 @@ + array_keys(TemplateRegistry::options())], + [['rescue_name', 'email', 'slug'], 'string', 'max' => 190], + [['address', 'icon_path', 'background_image_path'], 'string', 'max' => 255], + [['template_key'], 'string', 'max' => 64], + [['city'], 'string', 'max' => 120], + [['state'], 'string', 'max' => 2], + [['zip'], 'string', 'max' => 10], + [['phone'], 'string', 'max' => 32], + [['contentcontainer_id'], 'unique'], + [['slug'], 'unique'], + ]; + } +} diff --git a/models/forms/SpaceProfileForm.php b/models/forms/SpaceProfileForm.php new file mode 100644 index 0000000..4feae78 --- /dev/null +++ b/models/forms/SpaceProfileForm.php @@ -0,0 +1,291 @@ +profile = SpaceProfile::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]); + + if ($this->profile) { + $this->address = (string)$this->profile->address; + $this->city = (string)$this->profile->city; + $this->state = (string)$this->profile->state; + $this->zip = (string)$this->profile->zip; + $this->email = (string)$this->profile->email; + $this->phone = (string)$this->profile->phone; + $this->animals_we_accept = (string)$this->profile->animals_we_accept; + $this->mission_statement = (string)$this->profile->mission_statement; + $this->template_key = !empty($this->profile->template_key) + ? (string)$this->profile->template_key + : TemplateRegistry::TEMPLATE_RESCUE_CENTER; + $this->header_html = $this->profile->header_html; + $this->body_html = $this->profile->body_html; + $this->footer_html = $this->profile->footer_html; + } + + $this->rescue_name = $this->getSpaceName(); + $this->description = $this->getSpaceDescription(); + } + + public function rules() + { + $contactRules = ValidationStandards::contactRules([ + 'email' => 'email', + 'phone' => 'phone', + 'state' => 'state', + 'zip' => 'zip', + ]); + + return array_merge([ + [['address', 'city', 'state', 'zip', 'email', 'phone', 'animals_we_accept', 'mission_statement', 'template_key'], 'required'], + [['animals_we_accept', 'description', 'mission_statement', 'header_html', 'body_html', 'footer_html'], 'string'], + [['template_key'], 'in', 'range' => array_keys(TemplateRegistry::options())], + [['rescue_name', 'email'], 'string', 'max' => 190], + [['address'], 'string', 'max' => 255], + [['city'], 'string', 'max' => 120], + [['state'], 'string', 'max' => 2], + [['zip'], 'string', 'max' => 10], + [['phone'], 'string', 'max' => 32], + [['template_key'], 'string', 'max' => 64], + [['removeIcon', 'removeBackgroundImage'], 'boolean'], + [['iconFile', 'backgroundImageFile'], 'file', + 'skipOnEmpty' => true, + 'extensions' => UploadStandards::imageExtensions(), + 'checkExtensionByMimeType' => true, + 'mimeTypes' => UploadStandards::imageMimeTypes(), + 'maxSize' => UploadStandards::IMAGE_MAX_BYTES, + ], + ], $contactRules); + } + + public function save(): bool + { + if (!$this->validate()) { + return false; + } + + $persistedDescription = $this->resolvePersistedDescription(); + + $profile = $this->profile ?: new SpaceProfile(); + $profile->contentcontainer_id = $this->contentContainer->contentcontainer_id; + $profile->rescue_name = $this->getSpaceName(); + $profile->address = trim($this->address); + $profile->city = trim($this->city); + $profile->state = strtoupper(trim($this->state)); + $profile->zip = trim($this->zip); + $profile->email = trim($this->email); + $profile->phone = trim($this->phone); + $profile->animals_we_accept = trim($this->animals_we_accept); + $profile->description = $persistedDescription; + $profile->mission_statement = trim($this->mission_statement); + $profile->slug = $this->generateUniqueSlug(); + $profile->template_key = $this->template_key; + $profile->header_html = ProfileHtmlSanitizer::sanitize($this->header_html); + $profile->body_html = ProfileHtmlSanitizer::sanitize($this->body_html); + $profile->footer_html = ProfileHtmlSanitizer::sanitize($this->footer_html); + + if ($this->removeIcon) { + $this->deleteStoredFile($profile->icon_path); + $profile->icon_path = null; + } + + if ($this->removeBackgroundImage) { + $this->deleteStoredFile($profile->background_image_path); + $profile->background_image_path = null; + } + + if ($this->iconFile instanceof UploadedFile) { + $this->deleteStoredFile($profile->icon_path); + $profile->icon_path = $this->storeImage($this->iconFile, 'icon'); + } + + if ($this->backgroundImageFile instanceof UploadedFile) { + $this->deleteStoredFile($profile->background_image_path); + $profile->background_image_path = $this->storeImage($this->backgroundImageFile, 'background'); + } + + if (!$profile->save()) { + $this->addErrors($profile->getErrors()); + return false; + } + + $this->setDefaultHomePage(); + $this->profile = $profile; + return true; + } + + public function getProfile(): ?SpaceProfile + { + return $this->profile; + } + + public function getTemplateOptions(): array + { + return TemplateRegistry::options(); + } + + private function storeImage(UploadedFile $file, string $prefix): ?string + { + $relativeDir = '/uploads/space-profiles/' . $this->contentContainer->contentcontainer_id; + $absoluteDir = Yii::getAlias('@webroot') . $relativeDir; + FileHelper::createDirectory($absoluteDir); + + $random = Yii::$app->security->generateRandomString(8); + $fileName = $prefix . '-' . time() . '-' . $random . '.' . strtolower((string)$file->extension); + $absolutePath = $absoluteDir . '/' . $fileName; + + if (!$file->saveAs($absolutePath)) { + return null; + } + + return $relativeDir . '/' . $fileName; + } + + private function deleteStoredFile(?string $relativePath): void + { + if (empty($relativePath)) { + return; + } + + $absolutePath = Yii::getAlias('@webroot') . $relativePath; + if (is_file($absolutePath)) { + @unlink($absolutePath); + } + } + + private function buildBaseSlug(): string + { + $source = $this->getSpaceName(); + + $normalized = trim((string)$source); + $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); + if ($ascii !== false) { + $normalized = $ascii; + } + + $slug = strtolower($normalized); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; + $slug = trim($slug, '-'); + + return $slug !== '' ? $slug : 'rescue'; + } + + private function generateUniqueSlug(): string + { + $base = $this->buildBaseSlug(); + $candidate = $base; + $suffix = 1; + + while ($this->slugExists($candidate)) { + $candidate = $base . '-' . $suffix; + $suffix++; + } + + return $candidate; + } + + private function slugExists(string $slug): bool + { + $query = SpaceProfile::find()->where(['slug' => $slug]); + + if ($this->profile && !$this->profile->isNewRecord) { + $query->andWhere(['!=', 'id', (int)$this->profile->id]); + } + + return $query->exists(); + } + + private function setDefaultHomePage(): void + { + if (!$this->contentContainer instanceof Space) { + return; + } + + $defaultUrl = $this->contentContainer->createUrl('/space_profiles/profile/view'); + $settings = $this->contentContainer->getSettings(); + $settings->set('indexUrl', $defaultUrl); + $settings->set('indexGuestUrl', $defaultUrl); + } + + private function getSpaceName(): string + { + if ($this->contentContainer instanceof Space) { + return trim((string)$this->contentContainer->name); + } + + return trim($this->rescue_name); + } + + private function getSpaceDescription(): string + { + if ($this->contentContainer instanceof Space) { + $about = trim((string)$this->contentContainer->about); + if ($about !== '') { + return $about; + } + + return trim((string)$this->contentContainer->description); + } + + return trim($this->description); + } + + private function resolvePersistedDescription(): string + { + $description = $this->getSpaceDescription(); + if ($description !== '') { + return $description; + } + + $mission = trim($this->mission_statement); + if ($mission !== '') { + return $mission; + } + + $animals = trim($this->animals_we_accept); + if ($animals !== '') { + return $animals; + } + + return $this->getSpaceName(); + } +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..5775b32 --- /dev/null +++ b/module.json @@ -0,0 +1,15 @@ +{ + "id": "space_profiles", + "name": "Space Profiles", + "description": "Template-driven rescue space profile pages with branding and controlled HTML regions.", + "keywords": ["space", "profile", "rescue", "branding"], + "version": "0.1.0", + "humhub": { + "minVersion": "1.14" + }, + "authors": [ + { + "name": "Kelin Rescue Hub" + } + ] +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ae7d091 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,19 @@ +# Space Profiles Test Notes + +This module includes a unit suite scaffold under `tests/codeception/unit`. + +Current unit coverage targets: +- HTML sanitization hardening regressions +- Public/profile access helper behavior +- Form-level validation behavior for core profile fields + +## Run + +In a HumHub development environment with Codeception installed: + +```bash +cd protected +php vendor/bin/codecept run -c modules/space_profiles/tests/codeception.yml unit +``` + +If `vendor/bin/codecept` is not present, install dev dependencies for the HumHub environment first. diff --git a/tests/codeception.yml b/tests/codeception.yml new file mode 100644 index 0000000..ee9a870 --- /dev/null +++ b/tests/codeception.yml @@ -0,0 +1,18 @@ +actor: Tester +namespace: space_profiles +bootstrap: _bootstrap.php +settings: + suite_class: \PHPUnit_Framework_TestSuite + colors: true + shuffle: false + memory_limit: 1024M + log: true + backup_globals: true +paths: + tests: codeception + log: codeception/_output + data: codeception/_data + helpers: codeception/_support + envs: ../../../humhub/tests/config/env +config: + test_entry_url: http://localhost:8080/index-test.php diff --git a/tests/codeception/_bootstrap.php b/tests/codeception/_bootstrap.php new file mode 100644 index 0000000..b052e61 --- /dev/null +++ b/tests/codeception/_bootstrap.php @@ -0,0 +1,26 @@ + $testRoot]); +codecept_debug('Module root: ' . $testRoot); + +$humhubPath = getenv('HUMHUB_PATH'); +if ($humhubPath === false) { + $moduleConfig = require $testRoot . '/config/test.php'; + if (isset($moduleConfig['humhub_root'])) { + $humhubPath = $moduleConfig['humhub_root']; + } else { + $humhubPath = dirname(__DIR__, 5); + } +} + +\Codeception\Configuration::append(['humhub_root' => $humhubPath]); +codecept_debug('HumHub Root: ' . $humhubPath); + +$globalConfig = require $humhubPath . '/protected/humhub/tests/codeception/_loadConfig.php'; +require $globalConfig['humhub_root'] . '/protected/humhub/tests/codeception/_bootstrap.php'; diff --git a/tests/codeception/_support/UnitTester.php b/tests/codeception/_support/UnitTester.php new file mode 100644 index 0000000..7f9b38d --- /dev/null +++ b/tests/codeception/_support/UnitTester.php @@ -0,0 +1,7 @@ +createSpaceMock(Space::VISIBILITY_ALL, true, false); + + $this->assertTrue(ProfileAccess::canView($space, null)); + } + + public function testMembersOnlyBlocksGuestAndAllowsUser(): void + { + $space = $this->createSpaceMock(Space::VISIBILITY_REGISTERED_ONLY, true, false); + $user = $this->createUserMock(false); + + $this->assertFalse(ProfileAccess::canView($space, null)); + $this->assertTrue(ProfileAccess::canView($space, $user)); + } + + public function testPrivateSpaceRequiresMembership(): void + { + $user = $this->createUserMock(false); + $nonMemberSpace = $this->createSpaceMock(Space::VISIBILITY_NONE, true, false); + $memberSpace = $this->createSpaceMock(Space::VISIBILITY_NONE, true, true); + + $this->assertFalse(ProfileAccess::canView($nonMemberSpace, $user)); + $this->assertTrue(ProfileAccess::canView($memberSpace, $user)); + } + + public function testDisabledSpaceDenied(): void + { + $space = $this->createSpaceMock(Space::VISIBILITY_ALL, false, true); + + $this->assertFalse(ProfileAccess::canView($space, $this->createUserMock(true))); + } + + private function createSpaceMock(int $visibility, bool $enabled, bool $isMember): Space + { + $space = $this->getMockBuilder(Space::class) + ->disableOriginalConstructor() + ->onlyMethods(['isMember']) + ->getMock(); + + $space->visibility = $visibility; + $space->status = $enabled ? Space::STATUS_ENABLED : Space::STATUS_ARCHIVED; + $space->method('isMember')->willReturn($isMember); + + return $space; + } + + private function createUserMock(bool $isAdmin): User + { + $user = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->onlyMethods(['isSystemAdmin']) + ->getMock(); + + $user->method('isSystemAdmin')->willReturn($isAdmin); + + return $user; + } +} diff --git a/tests/codeception/unit/ProfileHtmlSanitizerTest.php b/tests/codeception/unit/ProfileHtmlSanitizerTest.php new file mode 100644 index 0000000..0edfd44 --- /dev/null +++ b/tests/codeception/unit/ProfileHtmlSanitizerTest.php @@ -0,0 +1,40 @@ +alert(1)
OK
'; + + $output = ProfileHtmlSanitizer::sanitize($input); + + $this->assertStringNotContainsString('assertStringNotContainsString('onclick=', $output); + $this->assertStringContainsString('
OK
', $output); + } + + public function testSanitizeStripsJavascriptUris(): void + { + $input = 'badgood'; + + $output = ProfileHtmlSanitizer::sanitize($input); + + $this->assertStringNotContainsString('javascript:', $output); + $this->assertStringContainsString('https://example.org', $output); + } + + public function testSanitizeScopesEmbeddedCss(): void + { + $input = '
Title
'; + + $output = ProfileHtmlSanitizer::sanitize($input); + + $this->assertStringContainsString('.rescue-profile-scope', $output); + $this->assertStringContainsString('.rescue-profile-scope .hdr', $output); + $this->assertStringNotContainsString('