Initial push
This commit is contained in:
7
hubzilla/addon/assoc_profile/assoc_profile.apd
Normal file
7
hubzilla/addon/assoc_profile/assoc_profile.apd
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
url: $baseurl/assoc_profile
|
||||||
|
requires: local_channel
|
||||||
|
name: Association Profile
|
||||||
|
photo: icon:building
|
||||||
|
categories: Civic Diagnostics
|
||||||
|
desc: Structured diagnostic identity fields for HOA association channels.
|
||||||
378
hubzilla/addon/assoc_profile/assoc_profile.php
Normal file
378
hubzilla/addon/assoc_profile/assoc_profile.php
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name: Association Profile
|
||||||
|
* Description: Structured diagnostic identity fields for HOA association channels.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* MinVersion: 11.0
|
||||||
|
* MaxVersion: 12.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
function assoc_profile_module() {}
|
||||||
|
|
||||||
|
function assoc_profile_load() {
|
||||||
|
register_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook');
|
||||||
|
register_hook('profile_view', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_profile_unload() {
|
||||||
|
unregister_hook('profile_edit', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_edit_hook');
|
||||||
|
unregister_hook('profile_view', 'addon/assoc_profile/assoc_profile.php', 'assoc_profile_view_hook');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// HELPERS
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_h($value) {
|
||||||
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_slug_from_address($channel_address) {
|
||||||
|
// channel_address is "slug@node" — extract the slug
|
||||||
|
$parts = explode('@', $channel_address);
|
||||||
|
return $parts[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// REGISTRY I/O
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_load_config() {
|
||||||
|
$path = 'addon/assoc_profile/config.json';
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false) return [];
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return (json_last_error() === JSON_ERROR_NONE) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_load_registry() {
|
||||||
|
$config = assoc_load_config();
|
||||||
|
$path = $config['registry_file']
|
||||||
|
?? 'addon/assoc_profile/assoc_registry.json';
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
logger('assoc_profile: registry file not found: ' . $path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
logger('assoc_profile: registry file malformed: ' . $path);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Strip meta keys
|
||||||
|
foreach (['_note','_version','_slug_format','_select_values'] as $k) {
|
||||||
|
unset($data[$k]);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_write_registry($data) {
|
||||||
|
$config = assoc_load_config();
|
||||||
|
$path = $config['registry_file']
|
||||||
|
?? 'addon/assoc_profile/assoc_registry.json';
|
||||||
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
logger('assoc_profile: failed to encode registry');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$result = file_put_contents($path, $json, LOCK_EX);
|
||||||
|
if ($result === false) {
|
||||||
|
logger('assoc_profile: failed to write registry: ' . $path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_get($slug) {
|
||||||
|
$registry = assoc_load_registry();
|
||||||
|
return $registry[$slug] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// VALIDATION
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_allowed_values() {
|
||||||
|
return [
|
||||||
|
'assoc_type' => ['Condominium','Master','CIC','Unincorporated','UNK','Disputed'],
|
||||||
|
'assoc_mgmt_certified' => ['Yes','No','UNK'],
|
||||||
|
'assoc_sos_standing' => ['Active','Dissolved','UNK'],
|
||||||
|
'assoc_declaration_recorded' => ['Yes','No','UNK'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_validate_select($field, $value) {
|
||||||
|
$allowed = assoc_allowed_values();
|
||||||
|
if (!isset($allowed[$field])) return true;
|
||||||
|
return in_array($value, $allowed[$field], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_field_names() {
|
||||||
|
return [
|
||||||
|
'assoc_legal_name', 'assoc_type', 'assoc_statute',
|
||||||
|
'assoc_street', 'assoc_city', 'assoc_county', 'assoc_state',
|
||||||
|
'assoc_zip', 'assoc_placekey', 'assoc_website',
|
||||||
|
'assoc_buildings', 'assoc_units', 'assoc_year_built', 'assoc_stories',
|
||||||
|
'assoc_mgmt_company', 'assoc_mgmt_contact', 'assoc_mgmt_certified',
|
||||||
|
'assoc_sos_id', 'assoc_sos_standing',
|
||||||
|
'assoc_declaration_recorded', 'assoc_declaration_instrument',
|
||||||
|
'assoc_registered', 'assoc_updated',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// PROFILE EDIT HOOK
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_profile_edit_hook(&$b) {
|
||||||
|
if (!local_channel()) return;
|
||||||
|
|
||||||
|
$channel = App::get_channel();
|
||||||
|
$slug = assoc_slug_from_address($channel['channel_address'] ?? '');
|
||||||
|
if (!$slug) return;
|
||||||
|
|
||||||
|
$entry = assoc_get($slug);
|
||||||
|
if (!$entry) return;
|
||||||
|
|
||||||
|
// Only channel owner may edit
|
||||||
|
if (local_channel() !== intval($channel['channel_id'])) return;
|
||||||
|
|
||||||
|
if (function_exists('head_add_css')) {
|
||||||
|
head_add_css('/addon/assoc_profile/view/css/assoc_profile.css');
|
||||||
|
}
|
||||||
|
|
||||||
|
$b['html'] .= assoc_render_edit_form($slug, $entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// PROFILE VIEW HOOK
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_profile_view_hook(&$b) {
|
||||||
|
$profile = $b['profile'] ?? [];
|
||||||
|
$uid = intval($profile['uid'] ?? 0);
|
||||||
|
if (!$uid) return;
|
||||||
|
|
||||||
|
$r = q("SELECT channel_address FROM channel WHERE channel_id = %d LIMIT 1",
|
||||||
|
intval($uid));
|
||||||
|
if (!$r) return;
|
||||||
|
|
||||||
|
$slug = assoc_slug_from_address($r[0]['channel_address']);
|
||||||
|
$entry = assoc_get($slug);
|
||||||
|
if (!$entry) return;
|
||||||
|
|
||||||
|
if (function_exists('head_add_css')) {
|
||||||
|
head_add_css('/addon/assoc_profile/view/css/assoc_profile.css');
|
||||||
|
}
|
||||||
|
|
||||||
|
$b['html'] .= assoc_render_view($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// RENDER — VIEW
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_render_view($entry) {
|
||||||
|
$labels = assoc_field_labels();
|
||||||
|
$groups = assoc_field_groups();
|
||||||
|
|
||||||
|
$out = '<div class="assoc-profile-view">';
|
||||||
|
$out .= '<h4 class="assoc-profile-heading">Association Diagnostic Profile</h4>';
|
||||||
|
|
||||||
|
foreach ($groups as $group_label => $fields) {
|
||||||
|
$out .= '<div class="assoc-profile-group">';
|
||||||
|
$out .= '<h5 class="assoc-group-label">' . assoc_h($group_label) . '</h5>';
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$val = $entry[$field] ?? '';
|
||||||
|
if ($val === '') $val = '—';
|
||||||
|
if (in_array($val, ['UNK'])) $val = 'Unknown — not yet verified';
|
||||||
|
$out .= '<div class="assoc-profile-field">';
|
||||||
|
$out .= '<span class="assoc-field-label">' . assoc_h($labels[$field] ?? $field) . '</span>';
|
||||||
|
$out .= '<span class="assoc-field-value">' . assoc_h($val) . '</span>';
|
||||||
|
$out .= '</div>';
|
||||||
|
}
|
||||||
|
$out .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$out .= '</div>';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// RENDER — EDIT FORM
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_render_edit_form($slug, $entry) {
|
||||||
|
$labels = assoc_field_labels();
|
||||||
|
$groups = assoc_field_groups();
|
||||||
|
$allowed = assoc_allowed_values();
|
||||||
|
|
||||||
|
$out = '<div class="assoc-profile-edit">';
|
||||||
|
$out .= '<h4 class="assoc-profile-heading">Association Diagnostic Profile</h4>';
|
||||||
|
$out .= '<form method="post" action="/assoc_profile/save" class="assoc-edit-form">';
|
||||||
|
$out .= '<input type="hidden" name="assoc_slug" value="' . assoc_h($slug) . '">';
|
||||||
|
$out .= assoc_csrf_token();
|
||||||
|
|
||||||
|
foreach ($groups as $group_label => $fields) {
|
||||||
|
$out .= '<div class="assoc-profile-group">';
|
||||||
|
$out .= '<h5 class="assoc-group-label">' . assoc_h($group_label) . '</h5>';
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (in_array($field, ['assoc_registered', 'assoc_updated'])) continue;
|
||||||
|
$val = $entry[$field] ?? '';
|
||||||
|
$label = $labels[$field] ?? $field;
|
||||||
|
if (isset($allowed[$field])) {
|
||||||
|
$out .= assoc_render_select($field, $label, $val, $allowed[$field]);
|
||||||
|
} else {
|
||||||
|
$out .= assoc_render_text($field, $label, $val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$out .= '<div class="mt-3">';
|
||||||
|
$out .= '<button type="submit" class="btn btn-primary btn-sm">Save Association Profile</button>';
|
||||||
|
$out .= '</div>';
|
||||||
|
$out .= '</form></div>';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_render_text($field, $label, $value) {
|
||||||
|
$id = assoc_h($field);
|
||||||
|
return '<div class="assoc-field-wrap mb-2">
|
||||||
|
<label class="form-label" for="' . $id . '">' . assoc_h($label) . '</label>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
id="' . $id . '" name="' . $id . '"
|
||||||
|
value="' . assoc_h($value) . '">
|
||||||
|
</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_render_select($field, $label, $value, $options) {
|
||||||
|
$id = assoc_h($field);
|
||||||
|
$out = '<div class="assoc-field-wrap mb-2">';
|
||||||
|
$out .= '<label class="form-label" for="' . $id . '">' . assoc_h($label) . '</label>';
|
||||||
|
$out .= '<select class="form-select form-select-sm" id="' . $id . '" name="' . $id . '">';
|
||||||
|
foreach ($options as $opt) {
|
||||||
|
$sel = ($value === $opt) ? 'selected' : '';
|
||||||
|
$disp = ($opt === 'UNK') ? 'Unknown — not yet verified' : $opt;
|
||||||
|
$out .= '<option value="' . assoc_h($opt) . '" ' . $sel . '>' . assoc_h($disp) . '</option>';
|
||||||
|
}
|
||||||
|
$out .= '</select></div>';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// SAVE HANDLER
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_profile_content() {
|
||||||
|
if (argv(1) !== 'save') return '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return '';
|
||||||
|
if (!local_channel()) return '';
|
||||||
|
if (!assoc_verify_csrf()) {
|
||||||
|
return '<div class="alert alert-danger">Invalid form token.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = substr(strip_tags($_POST['assoc_slug'] ?? ''), 0, 128);
|
||||||
|
if (!$slug) return '<div class="alert alert-danger">Missing association slug.</div>';
|
||||||
|
|
||||||
|
$registry = assoc_load_registry();
|
||||||
|
if (!isset($registry[$slug])) {
|
||||||
|
return '<div class="alert alert-danger">Association not found in registry.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify caller is channel owner for this slug
|
||||||
|
$channel = App::get_channel();
|
||||||
|
if (assoc_slug_from_address($channel['channel_address'] ?? '') !== $slug) {
|
||||||
|
return '<div class="alert alert-danger">Not authorized.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = assoc_allowed_values();
|
||||||
|
$fields = assoc_field_names();
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (in_array($field, ['assoc_registered', 'assoc_updated'])) continue;
|
||||||
|
if (!isset($_POST[$field])) continue;
|
||||||
|
$val = substr(strip_tags((string) $_POST[$field]), 0, 512);
|
||||||
|
if (isset($allowed[$field]) && !assoc_validate_select($field, $val)) continue;
|
||||||
|
$registry[$slug][$field] = $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry[$slug]['assoc_updated'] = date('Y-m-d');
|
||||||
|
|
||||||
|
if (assoc_write_registry($registry)) {
|
||||||
|
goaway(z_root() . '/profile/' . $slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="alert alert-danger">Failed to save. Check server logs.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// FIELD METADATA
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_field_labels() {
|
||||||
|
return [
|
||||||
|
'assoc_legal_name' => 'Legal Name',
|
||||||
|
'assoc_type' => 'Association Type',
|
||||||
|
'assoc_statute' => 'Governing Statute',
|
||||||
|
'assoc_street' => 'Street Address',
|
||||||
|
'assoc_city' => 'City',
|
||||||
|
'assoc_county' => 'County',
|
||||||
|
'assoc_state' => 'State',
|
||||||
|
'assoc_zip' => 'ZIP Code',
|
||||||
|
'assoc_placekey' => 'Placekey',
|
||||||
|
'assoc_website' => 'Association Website',
|
||||||
|
'assoc_buildings' => 'Number of Buildings',
|
||||||
|
'assoc_units' => 'Number of Units',
|
||||||
|
'assoc_year_built' => 'Year Built',
|
||||||
|
'assoc_stories' => 'Stories Per Building',
|
||||||
|
'assoc_mgmt_company' => 'Management Company',
|
||||||
|
'assoc_mgmt_contact' => 'Management Contact',
|
||||||
|
'assoc_mgmt_certified' => 'Manager IDFPR Certified',
|
||||||
|
'assoc_sos_id' => 'SOS Entity File Number',
|
||||||
|
'assoc_sos_standing' => 'Corporate Standing',
|
||||||
|
'assoc_declaration_recorded' => 'Declaration Recorded',
|
||||||
|
'assoc_declaration_instrument' => 'Declaration Instrument No.',
|
||||||
|
'assoc_registered' => 'Registered Date',
|
||||||
|
'assoc_updated' => 'Last Updated',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_field_groups() {
|
||||||
|
return [
|
||||||
|
'Identity' => [
|
||||||
|
'assoc_legal_name','assoc_type','assoc_statute',
|
||||||
|
'assoc_street','assoc_city','assoc_county',
|
||||||
|
'assoc_state','assoc_zip','assoc_placekey','assoc_website',
|
||||||
|
],
|
||||||
|
'Physical Structure' => [
|
||||||
|
'assoc_buildings','assoc_units','assoc_year_built','assoc_stories',
|
||||||
|
],
|
||||||
|
'Governance and Management' => [
|
||||||
|
'assoc_mgmt_company','assoc_mgmt_contact','assoc_mgmt_certified',
|
||||||
|
'assoc_sos_id','assoc_sos_standing',
|
||||||
|
'assoc_declaration_recorded','assoc_declaration_instrument',
|
||||||
|
],
|
||||||
|
'Record' => [
|
||||||
|
'assoc_registered','assoc_updated',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// CSRF
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function assoc_csrf_token() {
|
||||||
|
if (empty($_SESSION['assoc_profile_csrf'])) {
|
||||||
|
$_SESSION['assoc_profile_csrf'] = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
return '<input type="hidden" name="assoc_profile_csrf" value="'
|
||||||
|
. assoc_h($_SESSION['assoc_profile_csrf']) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function assoc_verify_csrf() {
|
||||||
|
return isset($_POST['assoc_profile_csrf'], $_SESSION['assoc_profile_csrf'])
|
||||||
|
&& hash_equals($_SESSION['assoc_profile_csrf'], $_POST['assoc_profile_csrf']);
|
||||||
|
}
|
||||||
37
hubzilla/addon/assoc_profile/assoc_registry.json.template
Normal file
37
hubzilla/addon/assoc_profile/assoc_registry.json.template
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"_note": "Copy to assoc_registry.json on the host. Do not commit assoc_registry.json — it contains operator-managed association data. Owned by www-data. One entry per association, keyed by slug.",
|
||||||
|
"_version": "1.0",
|
||||||
|
"_slug_format": "Lowercase, hyphens only. Must match the key in config.json associations and argv(1) in addon routes.",
|
||||||
|
"_select_values": {
|
||||||
|
"assoc_type": ["Condominium", "Master", "CIC", "Unincorporated", "UNK", "Disputed"],
|
||||||
|
"assoc_mgmt_certified": ["Yes", "No", "UNK"],
|
||||||
|
"assoc_sos_standing": ["Active", "Dissolved", "UNK"],
|
||||||
|
"assoc_declaration_recorded": ["Yes", "No", "UNK"]
|
||||||
|
},
|
||||||
|
"REPLACE-SLUG": {
|
||||||
|
"_note": "Replace REPLACE-SLUG with the association slug, e.g. arbors-bgca",
|
||||||
|
"assoc_legal_name": "REPLACE — full legal name as recorded with Illinois Secretary of State",
|
||||||
|
"assoc_type": "UNK",
|
||||||
|
"assoc_statute": "",
|
||||||
|
"assoc_street": "REPLACE — street address of the property",
|
||||||
|
"assoc_city": "REPLACE — city",
|
||||||
|
"assoc_county": "REPLACE — county, e.g. Cook County",
|
||||||
|
"assoc_state": "IL",
|
||||||
|
"assoc_zip": "REPLACE — ZIP code",
|
||||||
|
"assoc_placekey": "REPLACE — Placekey, e.g. 247-222@5sb-8bs-y35",
|
||||||
|
"assoc_website": "",
|
||||||
|
"assoc_buildings": "UNK",
|
||||||
|
"assoc_units": "UNK",
|
||||||
|
"assoc_year_built": "",
|
||||||
|
"assoc_stories": "",
|
||||||
|
"assoc_mgmt_company": "",
|
||||||
|
"assoc_mgmt_contact": "",
|
||||||
|
"assoc_mgmt_certified": "UNK",
|
||||||
|
"assoc_sos_id": "",
|
||||||
|
"assoc_sos_standing": "UNK",
|
||||||
|
"assoc_declaration_recorded": "UNK",
|
||||||
|
"assoc_declaration_instrument": "",
|
||||||
|
"assoc_registered": "REPLACE — ISO date, e.g. 2026-06-15",
|
||||||
|
"assoc_updated": "REPLACE — ISO date, e.g. 2026-06-15"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
hubzilla/addon/assoc_profile/config.json.template
Normal file
4
hubzilla/addon/assoc_profile/config.json.template
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_note": "Copy to config.json. Do not commit config.json.",
|
||||||
|
"registry_file": "REPLACE — absolute path to assoc_registry.json on the host, e.g. /var/www/hubzilla/addon/assoc_profile/assoc_registry.json"
|
||||||
|
}
|
||||||
5
hubzilla/addon/assoc_profile/mod_assoc_profile.pdl
Normal file
5
hubzilla/addon/assoc_profile/mod_assoc_profile.pdl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[template]default[/template]
|
||||||
|
|
||||||
|
[region=content]
|
||||||
|
$content
|
||||||
|
[/region]
|
||||||
Reference in New Issue
Block a user