diff --git a/hubzilla/addon/assoc_profile/assoc_profile.apd b/hubzilla/addon/assoc_profile/assoc_profile.apd new file mode 100644 index 0000000..8db10df --- /dev/null +++ b/hubzilla/addon/assoc_profile/assoc_profile.apd @@ -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. diff --git a/hubzilla/addon/assoc_profile/assoc_profile.php b/hubzilla/addon/assoc_profile/assoc_profile.php new file mode 100644 index 0000000..ade6295 --- /dev/null +++ b/hubzilla/addon/assoc_profile/assoc_profile.php @@ -0,0 +1,378 @@ + ['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 = '
'; + $out .= '

Association Diagnostic Profile

'; + + foreach ($groups as $group_label => $fields) { + $out .= '
'; + $out .= '
' . assoc_h($group_label) . '
'; + foreach ($fields as $field) { + $val = $entry[$field] ?? ''; + if ($val === '') $val = '—'; + if (in_array($val, ['UNK'])) $val = 'Unknown — not yet verified'; + $out .= '
'; + $out .= '' . assoc_h($labels[$field] ?? $field) . ''; + $out .= '' . assoc_h($val) . ''; + $out .= '
'; + } + $out .= '
'; + } + + $out .= '
'; + 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 = '
'; + $out .= '

Association Diagnostic Profile

'; + $out .= '
'; + $out .= ''; + $out .= assoc_csrf_token(); + + foreach ($groups as $group_label => $fields) { + $out .= '
'; + $out .= '
' . assoc_h($group_label) . '
'; + 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 .= '
'; + } + + $out .= '
'; + $out .= ''; + $out .= '
'; + $out .= '
'; + return $out; +} + +function assoc_render_text($field, $label, $value) { + $id = assoc_h($field); + return '
+ + +
'; +} + +function assoc_render_select($field, $label, $value, $options) { + $id = assoc_h($field); + $out = '
'; + $out .= ''; + $out .= '
'; + 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 '
Invalid form token.
'; + } + + $slug = substr(strip_tags($_POST['assoc_slug'] ?? ''), 0, 128); + if (!$slug) return '
Missing association slug.
'; + + $registry = assoc_load_registry(); + if (!isset($registry[$slug])) { + return '
Association not found in registry.
'; + } + + // Verify caller is channel owner for this slug + $channel = App::get_channel(); + if (assoc_slug_from_address($channel['channel_address'] ?? '') !== $slug) { + return '
Not authorized.
'; + } + + $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 '
Failed to save. Check server logs.
'; +} + +// ---------------------------------------------------------------------------- +// 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 ''; +} + +function assoc_verify_csrf() { + return isset($_POST['assoc_profile_csrf'], $_SESSION['assoc_profile_csrf']) + && hash_equals($_SESSION['assoc_profile_csrf'], $_POST['assoc_profile_csrf']); +} diff --git a/hubzilla/addon/assoc_profile/assoc_registry.json.template b/hubzilla/addon/assoc_profile/assoc_registry.json.template new file mode 100644 index 0000000..367a652 --- /dev/null +++ b/hubzilla/addon/assoc_profile/assoc_registry.json.template @@ -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" + } +} diff --git a/hubzilla/addon/assoc_profile/config.json.template b/hubzilla/addon/assoc_profile/config.json.template new file mode 100644 index 0000000..9d400bb --- /dev/null +++ b/hubzilla/addon/assoc_profile/config.json.template @@ -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" +} diff --git a/hubzilla/addon/assoc_profile/mod_assoc_profile.pdl b/hubzilla/addon/assoc_profile/mod_assoc_profile.pdl new file mode 100644 index 0000000..4bf8ec7 --- /dev/null +++ b/hubzilla/addon/assoc_profile/mod_assoc_profile.pdl @@ -0,0 +1,5 @@ +[template]default[/template] + +[region=content] +$content +[/region]