diff --git a/README.md b/README.md index c1976ac..8722066 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting | **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory | | **Calls** | YACA, PMA Voice, SaltyChat | | **Radio** | YACA, PMA Voice, SaltyChat | -| **Housing** | ESX Property, qbx_properties | +| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties | | **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge | | **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries | | **Languages** | English, German | @@ -525,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu ### Housing -Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority. +Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. ### Companies diff --git a/frontend/src/types/housing.ts b/frontend/src/types/housing.ts index 7c2dbfa..65c3cf7 100644 --- a/frontend/src/types/housing.ts +++ b/frontend/src/types/housing.ts @@ -3,6 +3,7 @@ export type HousingAccess = 'owner' | 'keyholder' export type HousingCapabilities = { cctv: boolean garageStatus: boolean + keyGrant?: boolean keys: boolean lock: boolean waypoint: boolean @@ -24,7 +25,7 @@ export type HousingProperty = { access: HousingAccess capabilities: HousingCapabilities cctv: { enabled: boolean } - entrance: { x: number; y: number; z: number } + entrance?: { x: number; y: number; z: number } garage: { enabled: boolean; storedVehicles: number } | null id: string keys?: HousingKey[] diff --git a/frontend/src/views/apps/HouseApp.contract.test.ts b/frontend/src/views/apps/HouseApp.contract.test.ts index eb3b873..d035955 100644 --- a/frontend/src/views/apps/HouseApp.contract.test.ts +++ b/frontend/src/views/apps/HouseApp.contract.test.ts @@ -44,6 +44,15 @@ describe('House app sheets', () => { ) }) + it('can expose key revocation without advertising key grants', () => { + expect(source).toContain( + 'v-if="selectedProperty.capabilities.keyGrant !== false"', + ) + expect(source).toContain( + '
', + ) + }) + it('centers the resident icon above the Give a Key title', () => { expect(source).toMatch( /\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s, diff --git a/frontend/src/views/apps/HouseApp.vue b/frontend/src/views/apps/HouseApp.vue index a7b5923..ce21932 100644 --- a/frontend/src/views/apps/HouseApp.vue +++ b/frontend/src/views/apps/HouseApp.vue @@ -464,6 +464,7 @@ onBeforeUnmount(() => { >{{ phone.t('Apps.house.keys') }} = Config.Housing.MaximumProperties then + break + end + end + end + end + table.sort(result, function(left, right) + if left.access ~= right.access then + return left.access == "owner" + end + return string.lower(left.name) < string.lower(right.name) + end) + return result + end, + prepare = function(source, action, data) + local property, details, property_id, access, error_code = resolve_property(source, data) + if not property then + return nil, error_code + end + + if action == "set_waypoint" then + return { coords = normalized_coords(property.coords) } + end + if action == "open_cctv" then + return nil, "cctv_unavailable" + end + if action == "key_candidates" then + if access ~= "owner" then + return nil, "owner_required" + end + local candidates, candidates_error = key_candidates(source, property_id, details) + return candidates and { candidates = candidates } or nil, candidates_error + end + if action == "grant_key" then + if access ~= "owner" then + return nil, "owner_required" + end + local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target) + if not target_identifier then + return nil, target_error + end + return { providerId = tostring(property_id), target = tonumber(data.target) } + end + if action == "revoke_key" then + if access ~= "owner" then + return nil, "owner_required" + end + local identifier = data.identifier + local keyholders = get_keyholders(property_id) + if not keyholders then + return nil, "provider_error" + end + if type(identifier) ~= "string" or not keyholder_exists(keyholders, identifier) then + return nil, "key_not_found" + end + return { providerId = tostring(property_id), identifier = identifier } + end + if action == "toggle_lock" then + if property.hasKey ~= true then + return nil, "capability_unavailable" + end + return { providerId = tostring(property_id) } + end + return nil, "invalid_action" + end, +}) + +Bridge.Callbacks.Register("sky_phone:housing:nolag:execute", function(source, data) + if type(data) ~= "table" or type(data.action) ~= "string" then + return { success = false, error = "invalid_request" } + end + return execute_action(source, data.action, data) +end) diff --git a/sky_phone/source/bridge/server/housing/quasar.lua b/sky_phone/source/bridge/server/housing/quasar.lua new file mode 100644 index 0000000..0bf1731 --- /dev/null +++ b/sky_phone/source/bridge/server/housing/quasar.lua @@ -0,0 +1,237 @@ +local provider_name = "quasar" +local resource_name = "qs-housing" + +local function table_like(value) + local value_type = type(value) + return value_type == "table" or value_type == "vector3" or value_type == "vector4" +end + +local function field(value, key) + if not table_like(value) then + return nil + end + local success, result = pcall(function() + return value[key] + end) + return success and result or nil +end + +local function decode_object(value) + if table_like(value) then + return value + end + if type(value) ~= "string" or value == "" then + return nil + end + local success, decoded = pcall(json.decode, value) + return success and table_like(decoded) and decoded or nil +end + +local function normalized_coords(value) + value = decode_object(value) + return value and Bridge.Normalize.Coordinates(value) or nil +end + +local function property_tables(property) + if not table_like(property) then + return {} + end + local result = { property } + for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do + local nested = decode_object(field(property, key)) + if nested and nested ~= property then + result[#result + 1] = nested + end + end + return result +end + +local function entrance_for(property) + for _, candidate in ipairs(property_tables(property)) do + for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do + local coords = normalized_coords(field(candidate, key)) + if coords then + return coords + end + end + + for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do + local container = decode_object(field(candidate, key)) + if container then + for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do + local coords = normalized_coords(field(container, nested_key)) + if coords then + return coords + end + end + local coords = normalized_coords(container) + if coords then + return coords + end + end + end + end + return nil +end + +local function non_empty_string(value) + if type(value) ~= "string" and type(value) ~= "number" then + return nil + end + local text = tostring(value):match("^%s*(.-)%s*$") + return text ~= "" and text or nil +end + +local function house_reference(key, property) + local direct = non_empty_string(property) + if direct then + return direct + end + if table_like(property) then + for _, name in ipairs({ + "house", "houseName", "house_name", "name", "identifier", + }) do + local value = non_empty_string(field(property, name)) + if value then + return value + end + end + end + if type(key) == "string" and not tonumber(key) then + return non_empty_string(key) + end + if table_like(property) then + for _, name in ipairs({ "propertyId", "property_id", "id" }) do + local value = non_empty_string(field(property, name)) + if value then + return value + end + end + end + return nil +end + +local function property_name(property, house) + for _, candidate in ipairs(property_tables(property)) do + for _, key in ipairs({ "label", "address", "adress", "displayName", "display_name", "name" }) do + local value = non_empty_string(field(candidate, key)) + if value then + return value + end + end + end + return house +end + +local function get_player_properties(source) + local success, properties = pcall(function() + return exports[resource_name]:GetPlayerHouses(source) + end) + if success and type(properties) == "table" then + return properties + end + + Bridge.Debug( + "error", + "[sky_phone] qs-housing:GetPlayerHouses failed for source %s: %s", + tostring(source), + tostring(properties) + ) + return nil, "provider_error" +end + +local function normalized_properties(source) + local properties, error_code = get_player_properties(source) + if not properties then + return nil, error_code + end + + local result = {} + local seen = {} + for key, property in pairs(properties) do + local house = house_reference(key, property) + if house and not seen[house] then + local entrance = entrance_for(property) + seen[house] = true + result[#result + 1] = { + id = provider_name .. ":" .. house, + providerId = house, + name = property_name(property, house), + access = "owner", + locked = false, + entrance = entrance, + capabilities = { + lock = false, + keys = false, + waypoint = true, + cctv = false, + garageStatus = false, + }, + cctv = { enabled = false }, + garage = nil, + keys = nil, + } + end + end + + table.sort(result, function(left, right) + return string.lower(left.name) < string.lower(right.name) + end) + + local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0)) + while #result > maximum do + result[#result] = nil + end + return result +end + +local function find_property(source, property_id) + if type(property_id) ~= "string" then + return nil, "invalid_property" + end + local house = property_id:match("^quasar:(.+)$") + if not house or house == "" then + return nil, "invalid_property" + end + + local properties, error_code = normalized_properties(source) + if not properties then + return nil, error_code + end + for _, property in ipairs(properties) do + if property.providerId == house then + return property + end + end + return nil, "property_access_denied" +end + +Bridge.Housing.RegisterProvider(provider_name, { + resource_name = resource_name, + is_available = function() + return GetResourceState(resource_name) == "started" + end, + get_overview = normalized_properties, + prepare = function(source, action, data) + if action == "toggle_lock" or action == "grant_key" or action == "revoke_key" + or action == "key_candidates" + then + return nil, "capability_unavailable" + end + if action == "open_cctv" then + return nil, "cctv_unavailable" + end + if action ~= "set_waypoint" then + return nil, "invalid_action" + end + + local property, error_code = find_property(source, data and data.propertyId) + if not property then + return nil, error_code + end + return { + providerId = property.providerId, + coords = property.entrance, + } + end, +}) diff --git a/sky_phone/source/bridge/server/housing/rtx.lua b/sky_phone/source/bridge/server/housing/rtx.lua new file mode 100644 index 0000000..40fa3ac --- /dev/null +++ b/sky_phone/source/bridge/server/housing/rtx.lua @@ -0,0 +1,547 @@ +local provider_name = "rtx" +local resource_name = "rtx_housing" + +local function provider_call(export_name, callback) + local success, result = pcall(callback) + if not success then + Bridge.Debug( + "error", + "[sky_phone] %s:%s failed: %s", + resource_name, + export_name, + tostring(result) + ) + return false, nil + end + return true, result +end + +local function normalize_property_id(value) + local number = Bridge.Normalize.FiniteNumber(value) + if not number or number < 1 or number ~= math.floor(number) then + return nil, nil + end + local integer = math.tointeger(number) + if not integer then + return nil, nil + end + return integer, tostring(integer) +end + +local function decode_table(value) + local value_type = type(value) + if value_type == "table" or value_type == "vector3" or value_type == "vector4" then + return value + end + if value_type ~= "string" or value == "" then + return nil + end + local success, decoded = pcall(json.decode, value) + return success and type(decoded) == "table" and decoded or nil +end + +local function normalized_coords(value) + local coords = Bridge.Normalize.Coordinates(value) + if not coords then + return nil + end + if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then + return nil + end + return coords +end + +local function nested_coords(value) + local data = decode_table(value) + if not data then + return nil + end + return normalized_coords(data.coords) or normalized_coords(data) +end + +local function property_zone_coords(property) + local zone = decode_table(property.propertyzone) + local points = zone and decode_table(zone.polyzonedata) or nil + if not points then + return nil + end + + local count = 0 + local x_total = 0.0 + local y_total = 0.0 + local z_total = 0.0 + local z_count = 0 + for _, point in pairs(points) do + local point_type = type(point) + if point_type == "table" or point_type == "vector3" or point_type == "vector4" then + local x = Bridge.Normalize.FiniteNumber(point.x) + local y = Bridge.Normalize.FiniteNumber(point.y) + local z = Bridge.Normalize.FiniteNumber(point.z) + if x and y then + count = count + 1 + x_total = x_total + x + y_total = y_total + y + if z then + z_count = z_count + 1 + z_total = z_total + z + end + end + end + end + if count == 0 then + return nil + end + + local z = z_count > 0 and z_total / z_count or nil + if not z then + local minimum_z = Bridge.Normalize.FiniteNumber(zone.minz) + local maximum_z = Bridge.Normalize.FiniteNumber(zone.maxz) + if minimum_z and maximum_z then + z = (minimum_z + maximum_z) / 2.0 + end + end + return normalized_coords({ + x = x_total / count, + y = y_total / count, + z = z, + }) +end + +local function property_entrance(property) + local coords = nested_coords(property.enter) + or nested_coords(property.sellsign) + if coords then + return coords + end + + local doors = decode_table(property.doors) + if doors then + for _, door in pairs(doors) do + local door_data = decode_table(door) + coords = door_data and nested_coords(door_data.door1) or nil + if coords then + return coords + end + end + end + + coords = nested_coords(property.garage) + return coords or property_zone_coords(property) +end + +local function property_identifier(property, fallback) + local value = type(property) == "table" + and (property.houseid or property.id or property.propertyid) + or nil + return normalize_property_id(value or fallback) +end + +local function get_all_properties() + local success, properties = provider_call("GetAllProperties", function() + return exports[resource_name]:GetAllProperties() + end) + if not success then + return nil, "provider_error" + end + if type(properties) ~= "table" then + Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name) + return nil, "provider_error" + end + return properties +end + +local function get_owned_properties(source) + local success, properties = provider_call("GetPlayerOwnedProperties", function() + return exports[resource_name]:GetPlayerOwnedProperties(source) + end) + if not success then + return nil, "provider_error" + end + if type(properties) ~= "table" then + Bridge.Debug("error", "[sky_phone] %s:GetPlayerOwnedProperties returned invalid data.", resource_name) + return nil, "provider_error" + end + return properties +end + +local function get_property(property_id) + local success, property = provider_call("GetPropertyData", function() + return exports[resource_name]:GetPropertyData(property_id) + end) + if not success then + return nil, "provider_error" + end + if property == nil then + return nil, "property_not_found" + end + if type(property) ~= "table" then + Bridge.Debug("error", "[sky_phone] %s:GetPropertyData returned invalid data.", resource_name) + return nil, "provider_error" + end + return property +end + +local function boolean_export(export_name, callback) + local success, value = provider_call(export_name, callback) + if not success then + return nil, "provider_error" + end + if type(value) ~= "boolean" then + Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name) + return nil, "provider_error" + end + return value +end + +local function lock_status(property_id) + return boolean_export("GetPropertyLockStatus", function() + return exports[resource_name]:GetPropertyLockStatus(property_id) + end) +end + +local function collect_catalog(catalog, properties) + local is_array = #properties > 0 + for key, property in pairs(properties) do + if type(property) == "table" then + local property_id, id_key = property_identifier(property, is_array and nil or key) + if property_id then + catalog[id_key] = { + id = property_id, + property = property, + } + end + end + end +end + +local function collect_owned(properties, catalog) + local owned = {} + local is_array = #properties > 0 + for key, value in pairs(properties) do + local property = type(value) == "table" and value or nil + local fallback = property and (is_array and nil or key) + or (is_array and value or key) + local property_id, id_key = property_identifier(property, fallback) + if property_id then + owned[id_key] = property_id + if catalog and property then + catalog[id_key] = { + id = property_id, + property = property, + } + end + end + end + return owned +end + +local function property_access(source, property, property_id, id_key, owned, identifier) + if owned[id_key] then + return "owner" + end + local owner = property.owner or property.Owner + if identifier and owner ~= nil and tostring(owner) == tostring(identifier) then + return "owner" + end + + local has_keys, error_code = boolean_export("CheckPropertyKeys", function() + return exports[resource_name]:CheckPropertyKeys(source, property_id) + end) + if has_keys == nil then + return nil, error_code + end + if has_keys then + return "keyholder" + end + + local has_permissions + has_permissions, error_code = boolean_export("HasPlayerAnyPropertyPermissions", function() + return exports[resource_name]:HasPlayerAnyPropertyPermissions(source, property_id) + end) + if has_permissions == nil then + return nil, error_code + end + return has_permissions and "keyholder" or nil +end + +local function can_control_lock(source, property_id, access) + if access == "owner" then + return true + end + return boolean_export("GetPropertyPermission", function() + return exports[resource_name]:GetPropertyPermission(source, property_id, "unlocking") + end) +end + +local function property_name(property, id_key) + local name = property.propertyname or property.name + if type(name) == "string" and name ~= "" then + return name + end + local address = property.adress or property.address + if type(address) == "string" and address ~= "" then + return address + end + return ("Property %s"):format(id_key) +end + +local function normalized_property(property, property_id, id_key, access, coords, locked, can_lock) + return { + id = ("%s:%s"):format(provider_name, id_key), + providerId = id_key, + name = property_name(property, id_key), + access = access, + locked = locked, + entrance = coords, + capabilities = { + lock = can_lock == true, + keys = false, + waypoint = true, + cctv = false, + garageStatus = false, + }, + cctv = { enabled = false }, + garage = nil, + keys = nil, + } +end + +local function actor_access(source, property, property_id, id_key) + local owned_properties, error_code = get_owned_properties(source) + if not owned_properties then + return nil, nil, error_code + end + local owned = collect_owned(owned_properties) + local identifier = Bridge.Framework.GetIdentifier(source) + local access + access, error_code = property_access( + source, + property, + property_id, + id_key, + owned, + identifier + ) + if error_code then + return nil, nil, error_code + end + if not access then + return nil, nil, "property_access_denied" + end + local can_lock + can_lock, error_code = can_control_lock(source, property_id, access) + if can_lock == nil then + return nil, nil, error_code + end + return access, can_lock +end + +local function resolve_property(source, data) + if type(data) ~= "table" or type(data.propertyId) ~= "string" then + return nil, nil, nil, nil, nil, "invalid_request" + end + local raw_id = data.propertyId:match("^rtx:(%d+)$") + local property_id, id_key = normalize_property_id(raw_id) + if not property_id then + return nil, nil, nil, nil, nil, "invalid_property" + end + + local property, error_code = get_property(property_id) + if not property then + return nil, nil, nil, nil, nil, error_code + end + local access, can_lock + access, can_lock, error_code = actor_access(source, property, property_id, id_key) + if not access then + return nil, nil, nil, nil, nil, error_code + end + local coords = property_entrance(property) + if not coords then + return nil, nil, nil, nil, nil, "invalid_coordinates" + end + return property, property_id, id_key, access, can_lock, nil, coords +end + +Bridge.Housing.RegisterProvider(provider_name, { + resource_name = resource_name, + is_available = function() + return GetResourceState(resource_name) == "started" + end, + get_overview = function(source) + local properties, error_code = get_all_properties() + if not properties then + return nil, error_code + end + local owned_properties + owned_properties, error_code = get_owned_properties(source) + if not owned_properties then + return nil, error_code + end + + local catalog = {} + collect_catalog(catalog, properties) + local owned = collect_owned(owned_properties, catalog) + for id_key, property_id in pairs(owned) do + if not catalog[id_key] then + local property + property, error_code = get_property(property_id) + if not property then + return nil, error_code + end + catalog[id_key] = { id = property_id, property = property } + end + end + + local identifier = Bridge.Framework.GetIdentifier(source) + local result = {} + for id_key, entry in pairs(catalog) do + local access + access, error_code = property_access( + source, + entry.property, + entry.id, + id_key, + owned, + identifier + ) + if error_code then + return nil, error_code + end + if access then + local coords = property_entrance(entry.property) + if coords then + local locked + locked, error_code = lock_status(entry.id) + if locked == nil then + return nil, error_code + end + local can_lock + can_lock, error_code = can_control_lock(source, entry.id, access) + if can_lock == nil then + return nil, error_code + end + result[#result + 1] = normalized_property( + entry.property, + entry.id, + id_key, + access, + coords, + locked, + can_lock + ) + else + Bridge.Debug( + "debug", + "[sky_phone] Ignored RTX property '%s' because no valid entrance was found.", + id_key + ) + end + end + end + + table.sort(result, function(left, right) + if left.access ~= right.access then + return left.access == "owner" + end + return string.lower(left.name) < string.lower(right.name) + end) + + local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50)) + local limited = {} + for index = 1, math.min(#result, maximum) do + limited[index] = result[index] + end + return limited + end, + prepare = function(source, action, data) + local property, property_id, id_key, access, can_lock, error_code, coords = + resolve_property(source, data) + if not property then + return nil, error_code + end + + if action == "set_waypoint" then + return { coords = coords } + end + if action == "toggle_lock" then + if not can_lock then + return nil, "property_access_denied" + end + return { providerId = id_key } + end + if action == "key_candidates" or action == "grant_key" or action == "revoke_key" then + return nil, "capability_unavailable" + end + if action == "open_cctv" then + return nil, "cctv_unavailable" + end + return nil, "invalid_action" + end, +}) + +local function execute_lock_action(source, data) + if not SkyPhone.AllowOperation(source, "housing_rtx_action", Config.Housing.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + if type(data) ~= "table" or data.action ~= "toggle_lock" then + return { success = false, error = "invalid_action" } + end + if GetResourceState(resource_name) ~= "started" then + return { success = false, error = "provider_unavailable" } + end + + local property_id, id_key = normalize_property_id(data.providerId) + if not property_id then + return { success = false, error = "invalid_property" } + end + local property, error_code = get_property(property_id) + if not property then + return { success = false, error = error_code } + end + + local access, can_lock + access, can_lock, error_code = actor_access(source, property, property_id, id_key) + if not access then + return { success = false, error = error_code } + end + if not can_lock then + return { success = false, error = "property_access_denied" } + end + + local current + current, error_code = lock_status(property_id) + if current == nil then + return { success = false, error = error_code } + end + local requested = not current + local setter_success = provider_call("SetPropertyLockStatus", function() + return exports[resource_name]:SetPropertyLockStatus(property_id, requested) + end) + if not setter_success then + return { success = false, error = "provider_error" } + end + + local verified + verified, error_code = lock_status(property_id) + if verified == nil then + return { success = false, error = error_code } + end + if verified ~= requested then + Bridge.Debug( + "error", + "[sky_phone] %s lock update verification failed for property %s.", + resource_name, + id_key + ) + return { success = false, error = "action_failed" } + end + return { success = true } +end + +Bridge.Callbacks.Register("sky_phone:housing:rtx:execute", function(source, data) + return execute_lock_action(source, data) +end) diff --git a/sky_phone/source/bridge/server/housing/rx.lua b/sky_phone/source/bridge/server/housing/rx.lua new file mode 100644 index 0000000..a4bc8f5 --- /dev/null +++ b/sky_phone/source/bridge/server/housing/rx.lua @@ -0,0 +1,730 @@ +local provider_name = "rx" +local resource_name = "RxHousing" + +local function positive_integer(value) + local number = Bridge.Normalize.FiniteNumber(value) + if not number or number < 1 or number ~= math.floor(number) then + return nil + end + return number +end + +local function table_like(value) + local value_type = type(value) + return value_type == "table" or value_type == "vector3" or value_type == "vector4" +end + +local function field(value, key) + if not table_like(value) then + return nil + end + local success, result = pcall(function() + return value[key] + end) + return success and result or nil +end + +local function decode_object(value) + if table_like(value) then + return value + end + if type(value) ~= "string" or value == "" then + return nil + end + local success, decoded = pcall(json.decode, value) + return success and table_like(decoded) and decoded or nil +end + +local function non_empty_string(value) + if type(value) ~= "string" and type(value) ~= "number" then + return nil + end + local text = tostring(value):match("^%s*(.-)%s*$") + return text ~= "" and text or nil +end + +local function normalized_coords(value) + value = decode_object(value) + return value and Bridge.Normalize.Coordinates(value) or nil +end + +local function entrance_for(property) + if not table_like(property) then + return nil + end + for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do + local coords = normalized_coords(field(property, key)) + if coords then + return coords + end + end + + for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do + local container = decode_object(field(property, key)) + if container then + for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do + local coords = normalized_coords(field(container, nested_key)) + if coords then + return coords + end + end + local coords = normalized_coords(container) + if coords then + return coords + end + end + end + return nil +end + +local function owner_identifier(property) + local owner = field(property, "owner") + local direct = non_empty_string(owner) + if direct then + return direct + end + if not table_like(owner) then + return nil + end + for _, key in ipairs({ "identifier", "citizenid", "citizenId", "id" }) do + local value = non_empty_string(field(owner, key)) + if value then + return value + end + end + return nil +end + +local function same_identifier(left, right) + return left ~= nil and right ~= nil and tostring(left) == tostring(right) +end + +local function property_name(property, property_id) + for _, key in ipairs({ "label", "name", "address", "adress" }) do + local value = non_empty_string(field(property, key)) + if value then + return value + end + end + return ("Property %s"):format(property_id) +end + +local function call_export(name, callback) + local success, result = pcall(callback) + if not success then + Bridge.Debug("error", "[sky_phone] RxHousing:%s failed: %s", name, tostring(result)) + return nil, false + end + return result, true +end + +local function get_all_properties() + local properties, success = call_export("GetAllProperties", function() + return exports[resource_name]:GetAllProperties() + end) + if not success or type(properties) ~= "table" then + if success then + Bridge.Debug("error", "[sky_phone] RxHousing:GetAllProperties returned invalid data.") + end + return nil, "provider_error" + end + return properties +end + +local function property_id_for(key, property) + if table_like(property) then + for _, name in ipairs({ "id", "propertyId", "property_id" }) do + local property_id = positive_integer(field(property, name)) + if property_id then + return property_id + end + end + end + return positive_integer(key) +end + +local function property_entries(properties) + local result = {} + local seen = {} + for key, value in pairs(properties) do + local property = decode_object(value) + local property_id = property and property_id_for(key, property) or nil + if property_id and not seen[property_id] then + seen[property_id] = true + result[#result + 1] = { id = property_id, data = property } + end + end + table.sort(result, function(left, right) + return left.id < right.id + end) + return result +end + +local function get_owned_property_ids(identifier) + local properties, success = call_export("GetOwnedProperties", function() + return exports[resource_name]:GetOwnedProperties(identifier) + end) + if not success or type(properties) ~= "table" then + if success then + Bridge.Debug("error", "[sky_phone] RxHousing:GetOwnedProperties returned invalid data.") + end + return nil, "provider_error" + end + + local result = {} + local is_array = #properties > 0 + local direct_id = property_id_for(nil, properties) + if direct_id then + result[direct_id] = true + return result + end + + for key, value in pairs(properties) do + local property = decode_object(value) + local property_id = property and property_id_for(key, property) + or positive_integer(value) + if not property_id and (value == true or value == 1) then + property_id = positive_integer(key) + end + if not property_id and not is_array then + property_id = positive_integer(key) + end + if property_id then + result[property_id] = true + end + end + return result +end + +local function get_property(property_id) + local property, success = call_export("GetProperty", function() + return exports[resource_name]:GetProperty(property_id) + end) + if not success then + return nil, "provider_error" + end + property = decode_object(property) + if not property then + return nil, "property_not_found" + end + return property +end + +local function boolean_result(value) + return value == true or value == 1 or value == "true" +end + +local function has_key(property_id, identifier) + local result, success = call_export("HasKey", function() + return exports[resource_name]:HasKey(property_id, identifier) + end) + if not success then + return nil, "provider_error" + end + return boolean_result(result) +end + +local function online_source(identifier) + for _, player_source in ipairs(Bridge.Framework.GetPlayers() or {}) do + if same_identifier(Bridge.Framework.GetIdentifier(player_source), identifier) then + return player_source + end + end + return nil +end + +local function player_name(player_source) + local first_name = Bridge.Framework.GetFirstname(player_source) + local last_name = Bridge.Framework.GetLastname(player_source) + local name = table.concat({ tostring(first_name or ""), tostring(last_name or "") }, " ") + :match("^%s*(.-)%s*$") + if name ~= "" then + return name + end + return GetPlayerName(player_source) or tostring(player_source) +end + +local function keyholder_identifier(key, value) + if type(value) == "string" then + return non_empty_string(value) + end + if table_like(value) then + for _, name in ipairs({ "identifier", "citizenid", "citizenId", "playerIdentifier", "player_identifier" }) do + local identifier = non_empty_string(field(value, name)) + if identifier then + return identifier + end + end + end + if type(key) == "string" and not tonumber(key) and (value == true or table_like(value)) then + return non_empty_string(key) + end + return nil +end + +local function supplied_keyholder_name(value) + if not table_like(value) then + return nil + end + local direct = non_empty_string(field(value, "name") or field(value, "label")) + if direct then + return direct + end + local first_name = non_empty_string(field(value, "firstname") or field(value, "firstName")) or "" + local last_name = non_empty_string(field(value, "lastname") or field(value, "lastName")) or "" + local name = (first_name .. " " .. last_name):match("^%s*(.-)%s*$") + return name ~= "" and name or nil +end + +local function get_keyholder_values(property_id) + local values, success = call_export("GetPropertyKeyholders", function() + return exports[resource_name]:GetPropertyKeyholders(property_id) + end) + if not success or type(values) ~= "table" then + if success then + Bridge.Debug("error", "[sky_phone] RxHousing:GetPropertyKeyholders returned invalid data.") + end + return nil, "provider_error" + end + return values +end + +local function normalized_keyholders(property_id, excluded_identifier) + local values, error_code = get_keyholder_values(property_id) + if not values then + return nil, error_code + end + + local result = {} + local seen = {} + for key, value in pairs(values) do + local identifier = keyholder_identifier(key, value) + if identifier and not same_identifier(identifier, excluded_identifier) and not seen[identifier] then + seen[identifier] = true + local player_source = online_source(identifier) + result[#result + 1] = { + identifier = identifier, + name = player_source and player_name(player_source) + or supplied_keyholder_name(value) + or identifier, + online = player_source ~= nil, + revocable = true, + } + end + end + table.sort(result, function(left, right) + return string.lower(left.name) < string.lower(right.name) + end) + return result +end + +local function access_for(property_id, identifier, owned_properties) + if owned_properties[property_id] then + return "owner" + end + local allowed, error_code = has_key(property_id, identifier) + if allowed == nil then + return nil, error_code + end + return allowed and "keyholder" or nil +end + +local function normalized_property(property_id, property, access, actor_identifier) + local entrance = entrance_for(property) + if not entrance then + return nil + end + + local keys = nil + if access == "owner" then + local error_code + keys, error_code = normalized_keyholders(property_id, actor_identifier) + if not keys then + return nil, error_code + end + end + + return { + id = ("rx:%s"):format(property_id), + providerId = tostring(property_id), + name = property_name(property, property_id), + access = access, + locked = false, + entrance = entrance, + capabilities = { + lock = false, + keys = access == "owner", + waypoint = true, + cctv = false, + garageStatus = false, + }, + cctv = { enabled = false }, + garage = nil, + keys = keys, + } +end + +local function get_overview(source) + local identifier = Bridge.Framework.GetIdentifier(source) + if not identifier then + return nil, "housing_unavailable" + end + local owned_properties, owned_error = get_owned_property_ids(identifier) + if not owned_properties then + return nil, owned_error + end + local properties, error_code = get_all_properties() + if not properties then + return nil, error_code + end + + local result = {} + for _, entry in ipairs(property_entries(properties)) do + local access, access_error = access_for(entry.id, identifier, owned_properties) + if access_error then + return nil, access_error + end + if access then + local property, normalize_error = normalized_property(entry.id, entry.data, access, identifier) + if normalize_error then + return nil, normalize_error + end + if property then + result[#result + 1] = property + end + end + end + + table.sort(result, function(left, right) + if left.access ~= right.access then + return left.access == "owner" + end + return string.lower(left.name) < string.lower(right.name) + end) + local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0)) + while #result > maximum do + result[#result] = nil + end + return result +end + +local function parse_property_id(value) + if type(value) ~= "string" then + return nil + end + return positive_integer(value:match("^rx:(%d+)$")) +end + +local function resolve_property(source, data) + local property_id = parse_property_id(data and data.propertyId) + if not property_id then + return nil, nil, nil, "invalid_property" + end + local property, error_code = get_property(property_id) + if not property then + return nil, nil, nil, error_code + end + local identifier = Bridge.Framework.GetIdentifier(source) + if not identifier then + return nil, nil, nil, "housing_unavailable" + end + local owned_properties, owned_error = get_owned_property_ids(identifier) + if not owned_properties then + return nil, nil, nil, owned_error + end + local access, access_error = access_for(property_id, identifier, owned_properties) + if access_error then + return nil, nil, nil, access_error + end + if not access then + return nil, nil, nil, "property_access_denied" + end + if not entrance_for(property) then + return nil, nil, nil, "invalid_coordinates" + end + return property, property_id, access +end + +local function player_source_for(key, value) + local direct = positive_integer(value) + if direct then + return direct + end + if table_like(value) then + for _, name in ipairs({ "source", "playerId", "player_id", "serverId", "server_id", "id" }) do + local player_source = positive_integer(field(value, name)) + if player_source then + return player_source + end + end + end + if value == true or table_like(value) then + return positive_integer(key) + end + return nil +end + +local function players_in_property(property_id) + local values, success = call_export("GetPlayersInProperty", function() + return exports[resource_name]:GetPlayersInProperty(property_id) + end) + if not success or type(values) ~= "table" then + if success then + Bridge.Debug("error", "[sky_phone] RxHousing:GetPlayersInProperty returned invalid data.") + end + return nil, "provider_error" + end + + local result = {} + local seen = {} + for key, value in pairs(values) do + local player_source = player_source_for(key, value) + if player_source and not seen[player_source] and GetPlayerName(player_source) then + seen[player_source] = true + result[#result + 1] = player_source + end + end + return result +end + +local function key_candidates(source, property, property_id) + local players, error_code = players_in_property(property_id) + if not players then + return nil, error_code + end + local owner = Bridge.Framework.GetIdentifier(source) or owner_identifier(property) + local result = {} + for _, target in ipairs(players) do + local identifier = Bridge.Framework.GetIdentifier(target) + if target ~= source and identifier and not same_identifier(identifier, owner) then + local allowed, access_error = has_key(property_id, identifier) + if allowed == nil then + return nil, access_error + end + if not allowed then + result[#result + 1] = { + id = target, + name = player_name(target), + } + end + end + end + table.sort(result, function(left, right) + return string.lower(left.name) < string.lower(right.name) + end) + return result +end + +local function has_keyholder(property_id, identifier) + local keyholders, error_code = normalized_keyholders(property_id) + if not keyholders then + return nil, error_code + end + for _, keyholder in ipairs(keyholders) do + if same_identifier(keyholder.identifier, identifier) then + return true + end + end + return false +end + +local function execute_key_action(source, data) + if not SkyPhone.AllowOperation(source, "housing_rx_action", Config.Housing.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + if GetResourceState(resource_name) ~= "started" then + return { success = false, error = "provider_unavailable" } + end + if type(data) ~= "table" or (data.action ~= "grant_key" and data.action ~= "revoke_key") then + return { success = false, error = "invalid_action" } + end + + local property_id = positive_integer(data.providerId) + if not property_id then + return { success = false, error = "invalid_property" } + end + local property, error_code = get_property(property_id) + if not property then + return { success = false, error = error_code } + end + if not entrance_for(property) then + return { success = false, error = "invalid_coordinates" } + end + local identifier = Bridge.Framework.GetIdentifier(source) + if not identifier then + return { success = false, error = "owner_required" } + end + local owned_properties, owned_error = get_owned_property_ids(identifier) + if not owned_properties then + return { success = false, error = owned_error } + end + if not owned_properties[property_id] then + return { success = false, error = "owner_required" } + end + + if data.action == "grant_key" then + local target = positive_integer(data.target) + local target_identifier = target and Bridge.Framework.GetIdentifier(target) or nil + if not target or target == source or not target_identifier or not GetPlayerName(target) then + return { success = false, error = "invalid_target" } + end + if same_identifier(target_identifier, identifier) then + return { success = false, error = "invalid_target" } + end + + local players, players_error = players_in_property(property_id) + if not players then + return { success = false, error = players_error } + end + local inside = false + for _, player_source in ipairs(players) do + if player_source == target then + inside = true + break + end + end + if not inside then + return { success = false, error = "target_not_in_property" } + end + + local allowed, access_error = has_key(property_id, target_identifier) + if allowed == nil then + return { success = false, error = access_error } + end + if allowed then + return { success = false, error = "key_already_exists" } + end + + local current_owned, current_error = get_owned_property_ids(identifier) + if not current_owned then + return { success = false, error = current_error } + end + if not current_owned[property_id] then + return { success = false, error = "owner_required" } + end + + local _, success = call_export("AddKeyholder", function() + return exports[resource_name]:AddKeyholder(property_id, target_identifier) + end) + if not success then + return { success = false, error = "provider_error" } + end + + local allowed_after, verify_error = has_key(property_id, target_identifier) + if allowed_after == nil then + return { success = false, error = verify_error } + end + return allowed_after + and { success = true } + or { success = false, error = "action_failed" } + end + + local target_identifier = non_empty_string(data.identifier) + if not target_identifier or same_identifier(target_identifier, identifier) then + return { success = false, error = "invalid_target" } + end + local exists, key_error = has_keyholder(property_id, target_identifier) + if exists == nil then + return { success = false, error = key_error } + end + if not exists then + return { success = false, error = "key_not_found" } + end + + local current_owned, current_error = get_owned_property_ids(identifier) + if not current_owned then + return { success = false, error = current_error } + end + if not current_owned[property_id] then + return { success = false, error = "owner_required" } + end + + local _, success = call_export("RemoveKeyholder", function() + return exports[resource_name]:RemoveKeyholder(property_id, target_identifier) + end) + if not success then + return { success = false, error = "provider_error" } + end + + local exists_after, verify_error = has_keyholder(property_id, target_identifier) + if exists_after == nil then + return { success = false, error = verify_error } + end + return not exists_after + and { success = true } + or { success = false, error = "action_failed" } +end + +Bridge.Housing.RegisterProvider(provider_name, { + resource_name = resource_name, + is_available = function() + return GetResourceState(resource_name) == "started" + end, + get_overview = get_overview, + prepare = function(source, action, data) + if action == "toggle_lock" then + return nil, "capability_unavailable" + end + if action == "open_cctv" then + return nil, "cctv_unavailable" + end + + local property, property_id, access, error_code = resolve_property(source, data) + if not property then + return nil, error_code + end + if action == "set_waypoint" then + return { coords = entrance_for(property) } + end + if access ~= "owner" then + return nil, "owner_required" + end + if action == "key_candidates" then + local candidates, candidates_error = key_candidates(source, property, property_id) + if not candidates then + return nil, candidates_error + end + return { candidates = candidates } + end + if action == "grant_key" then + local target = positive_integer(data and data.target) + if not target then + return nil, "invalid_target" + end + local candidates, candidates_error = key_candidates(source, property, property_id) + if not candidates then + return nil, candidates_error + end + for _, candidate in ipairs(candidates) do + if candidate.id == target then + return { providerId = tostring(property_id), target = target } + end + end + return nil, "target_not_in_property" + end + if action == "revoke_key" then + local target_identifier = non_empty_string(data and data.identifier) + local actor_identifier = Bridge.Framework.GetIdentifier(source) + if not target_identifier or same_identifier(target_identifier, actor_identifier) then + return nil, "invalid_target" + end + local exists, key_error = has_keyholder(property_id, target_identifier) + if exists == nil then + return nil, key_error + end + if not exists then + return nil, "key_not_found" + end + return { providerId = tostring(property_id), identifier = target_identifier } + end + return nil, "invalid_action" + end, +}) + +Bridge.Callbacks.Register("sky_phone:housing:rx:execute", function(source, data) + return execute_key_action(source, data) +end) diff --git a/sky_phone/source/bridge/server/housing/sn.lua b/sky_phone/source/bridge/server/housing/sn.lua new file mode 100644 index 0000000..da2b859 --- /dev/null +++ b/sky_phone/source/bridge/server/housing/sn.lua @@ -0,0 +1,393 @@ +local provider_name = "sn" +local resource_name = "sn_properties" + +local function same_identifier(left, right) + return left ~= nil and right ~= nil and tostring(left) == tostring(right) +end + +local function normalized_property_id(value) + if type(value) == "table" then + value = value.id or value.propertyId or value.property_id + end + local property_id = tonumber(value) + if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then + return nil + end + return property_id +end + +local normalized_coords = Bridge.Normalize.Coordinates + +local function get_all_properties() + local success, properties = pcall(function() + return exports[resource_name]:getAllProperties() + end) + if not success or type(properties) ~= "table" then + Bridge.Debug("error", "[sky_phone] sn_properties:getAllProperties failed: %s", tostring(properties)) + return nil + end + return properties +end + +local function collect_owned_property_ids(value, result) + local direct_id = normalized_property_id(value) + if direct_id then + result[direct_id] = true + return + end + if type(value) ~= "table" then + return + end + + local array_length = #value + local is_dense_array = array_length > 0 + if is_dense_array then + local entries = 0 + for key in pairs(value) do + entries = entries + 1 + if type(key) ~= "number" or key < 1 or key ~= math.floor(key) or key > array_length then + is_dense_array = false + end + end + is_dense_array = is_dense_array and entries == array_length + end + + for key, entry in pairs(value) do + local entry_id = normalized_property_id(entry) + if entry_id then + result[entry_id] = true + else + local key_id = normalized_property_id(key) + local keyed_property = key_id and entry ~= nil and entry ~= false + and (entry == true or type(key) == "string" or not is_dense_array) + if keyed_property then + result[key_id] = true + end + end + end +end + +local function get_owned_property_ids(source) + local success, properties = pcall(function() + return exports[resource_name]:getPlayerProperties(source) + end) + if not success then + Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties failed: %s", tostring(properties)) + return nil + end + + local result = {} + if properties == nil or properties == false then + return result + end + if type(properties) ~= "table" and type(properties) ~= "number" and type(properties) ~= "string" then + Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties returned an invalid value") + return nil + end + collect_owned_property_ids(properties, result) + return result +end + +local function player_source(identifier) + for _, source in ipairs(Bridge.Framework.GetPlayers()) do + if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then + return source + end + end + return nil +end + +local function player_name(source, fallback) + if source then + local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source) + if type(name) == "string" then + name = name:match("^%s*(.-)%s*$") + if name ~= "" then + return name + end + end + end + return tostring(fallback) +end + +local function property_keys(property) + return type(property.keys) == "table" and property.keys or {} +end + +local function property_access(property, identifier, owned_property_ids) + local property_id = normalized_property_id(property) + if property_id and owned_property_ids[property_id] then + return "owner" + end + if property_keys(property)[tostring(identifier)] ~= nil then + return "keyholder" + end + return nil +end + +local function normalized_keys(property, actor_identifier) + local keys = {} + for identifier in pairs(property_keys(property)) do + if (type(identifier) == "string" or type(identifier) == "number") + and not same_identifier(identifier, actor_identifier) + then + local normalized_identifier = tostring(identifier) + local source = player_source(normalized_identifier) + keys[#keys + 1] = { + identifier = normalized_identifier, + name = player_name(source, normalized_identifier), + online = source ~= nil, + revocable = true, + } + end + end + table.sort(keys, function(left, right) + return string.lower(left.name) < string.lower(right.name) + end) + return keys +end + +local function normalized_property(property, access, actor_identifier) + local property_id = normalized_property_id(property) + local entrance = normalized_coords(property.coords) + if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then + return nil + end + + local owner = access == "owner" + return { + id = ("sn:%s"):format(property_id), + providerId = tostring(property_id), + name = tostring(property.label or ("Property %s"):format(property_id)), + access = access, + locked = false, + entrance = entrance, + capabilities = { + lock = false, + keyGrant = false, + keys = owner, + waypoint = true, + cctv = false, + garageStatus = false, + }, + cctv = { enabled = false }, + garage = nil, + keys = owner and normalized_keys(property, actor_identifier) or nil, + } +end + +local function find_property(properties, property_id) + if normalized_property_id(properties) == property_id then + return properties + end + for _, property in pairs(properties) do + if type(property) == "table" and tonumber(property.id) == property_id then + return property + end + end + return nil +end + +local function parse_property_id(data) + if type(data) ~= "table" then + return nil, "invalid_request" + end + local property_id = type(data.propertyId) == "string" + and tonumber(data.propertyId:match("^sn:(%d+)$")) or nil + if not property_id then + property_id = tonumber(data.providerId) + end + if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then + return nil, "invalid_property" + end + return property_id +end + +local function resolve_property(source, data) + local property_id, error_code = parse_property_id(data) + if not property_id then + return nil, nil, nil, error_code + end + + local identifier = Bridge.Framework.GetIdentifier(source) + if not identifier then + return nil, nil, nil, "housing_unavailable" + end + identifier = tostring(identifier) + + local owned_property_ids = get_owned_property_ids(source) + if not owned_property_ids then + return nil, nil, nil, "provider_error" + end + + local properties = get_all_properties() + if not properties then + return nil, nil, nil, "provider_error" + end + local property = find_property(properties, property_id) + if not property or not normalized_coords(property.coords) then + return nil, nil, nil, "property_not_found" + end + + local access = property_access(property, identifier, owned_property_ids) + if not access then + return nil, nil, nil, "property_access_denied" + end + return property, property_id, access, nil, identifier, owned_property_ids +end + +local function execute_action(source, action, data) + if not SkyPhone.AllowOperation(source, "housing_sn_action", Config.Housing.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + + if action == "grant_key" then + return { success = false, error = "capability_unavailable" } + end + if action ~= "revoke_key" then + return { success = false, error = "invalid_action" } + end + + local property, property_id, access, error_code, actor_identifier, owned_property_ids = resolve_property(source, data) + if not property then + return { success = false, error = error_code } + end + if access ~= "owner" or not owned_property_ids[property_id] then + return { success = false, error = "owner_required" } + end + + local identifier = data.identifier + if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then + return { success = false, error = "invalid_target" } + end + if property_keys(property)[identifier] == nil then + return { success = false, error = "key_not_found" } + end + + local current_owned_property_ids = get_owned_property_ids(source) + if not current_owned_property_ids then + return { success = false, error = "provider_error" } + end + if not current_owned_property_ids[property_id] then + return { success = false, error = "owner_required" } + end + + local success, result = pcall(function() + return exports[resource_name]:removeKeyholder(identifier, property_id) + end) + if not success then + Bridge.Debug("error", "[sky_phone] sn_properties:removeKeyholder failed: %s", tostring(result)) + return { success = false, error = "provider_error" } + end + + local updated_properties = get_all_properties() + if not updated_properties then + return { success = false, error = "provider_error" } + end + local updated_property = find_property(updated_properties, property_id) + if not updated_property then + return { success = false, error = "property_not_found" } + end + if property_keys(updated_property)[identifier] ~= nil then + return { success = false, error = result == false and "provider_rejected" or "action_failed" } + end + return { success = true } +end + +Bridge.Housing.RegisterProvider(provider_name, { + resource_name = resource_name, + is_available = function() + return GetResourceState(resource_name) == "started" + end, + get_overview = function(source) + local identifier = Bridge.Framework.GetIdentifier(source) + if not identifier then + return nil, "housing_unavailable" + end + identifier = tostring(identifier) + + local owned_property_ids = get_owned_property_ids(source) + if not owned_property_ids then + return nil, "provider_error" + end + + local properties = get_all_properties() + if not properties then + return nil, "provider_error" + end + + local result = {} + for _, property in pairs(properties) do + if type(property) == "table" then + local access = property_access(property, identifier, owned_property_ids) + local normalized = access and normalized_property(property, access, identifier) or nil + if normalized then + result[#result + 1] = normalized + if #result >= Config.Housing.MaximumProperties then + break + end + end + end + end + table.sort(result, function(left, right) + if left.access ~= right.access then + return left.access == "owner" + end + return string.lower(left.name) < string.lower(right.name) + end) + return result + end, + prepare = function(source, action, data) + local property, property_id, access, error_code, actor_identifier = resolve_property(source, data) + if not property then + return nil, error_code + end + + if action == "set_waypoint" then + return { coords = normalized_coords(property.coords) } + end + if action == "open_cctv" then + return nil, "cctv_unavailable" + end + if action == "toggle_lock" then + return nil, "capability_unavailable" + end + if action == "key_candidates" then + if access ~= "owner" then + return nil, "owner_required" + end + return { candidates = {} } + end + if action == "grant_key" then + if access ~= "owner" then + return nil, "owner_required" + end + return nil, "capability_unavailable" + end + if action == "revoke_key" then + if access ~= "owner" then + return nil, "owner_required" + end + local identifier = data.identifier + if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then + return nil, "invalid_target" + end + if property_keys(property)[identifier] == nil then + return nil, "key_not_found" + end + return { providerId = tostring(property_id), identifier = identifier } + end + return nil, "invalid_action" + end, +}) + +Bridge.Callbacks.Register("sky_phone:housing:sn:execute", function(source, data) + if type(data) ~= "table" or type(data.action) ~= "string" then + return { success = false, error = "invalid_request" } + end + return execute_action(source, data.action, data) +end) diff --git a/sky_phone/source/bridge/server/housing/vms.lua b/sky_phone/source/bridge/server/housing/vms.lua new file mode 100644 index 0000000..49dd96b --- /dev/null +++ b/sky_phone/source/bridge/server/housing/vms.lua @@ -0,0 +1,428 @@ +local provider_name = "vms" +local resource_name = "vms_housing" + +local function provider_call(export_name, callback) + local success, result = pcall(callback) + if not success then + Bridge.Debug( + "error", + "[sky_phone] %s:%s failed: %s", + resource_name, + export_name, + tostring(result) + ) + return false, nil + end + return true, result +end + +local function normalize_property_id(value) + if type(value) == "number" then + local number = Bridge.Normalize.FiniteNumber(value) + if not number or number < 1 or number ~= math.floor(number) then + return nil, nil + end + local integer = math.tointeger(number) + return integer, integer and tostring(integer) or nil + end + if type(value) ~= "string" then + return nil, nil + end + local normalized = value:match("^%s*(.-)%s*$") + if normalized == "" or #normalized > 128 or normalized:find("%c") then + return nil, nil + end + return normalized, normalized +end + +local function decode_table(value) + local value_type = type(value) + if value_type == "table" or value_type == "vector3" or value_type == "vector4" then + return value + end + if value_type ~= "string" or value == "" then + return nil + end + local success, decoded = pcall(json.decode, value) + return success and type(decoded) == "table" and decoded or nil +end + +local function normalized_coords(value) + local coords = Bridge.Normalize.Coordinates(value) + if not coords then + return nil + end + if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then + return nil + end + return coords +end + +local function property_identifier(property, fallback) + local value = type(property) == "table" + and (property.id or property.propertyId or property.property_id) + or nil + return normalize_property_id(value or fallback) +end + +local function get_all_properties() + local success, properties = provider_call("GetAllProperties", function() + return exports[resource_name]:GetAllProperties() + end) + if not success then + return nil, "provider_error" + end + if type(properties) ~= "table" then + Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name) + return nil, "provider_error" + end + return properties +end + +local function get_player_properties(source) + local success, properties = provider_call("GetPlayerProperties", function() + return exports[resource_name]:GetPlayerProperties(source) + end) + if not success then + return nil, "provider_error" + end + if type(properties) ~= "table" then + Bridge.Debug("error", "[sky_phone] %s:GetPlayerProperties returned invalid data.", resource_name) + return nil, "provider_error" + end + return properties +end + +local function get_property(property_id) + local success, property = provider_call("GetProperty", function() + return exports[resource_name]:GetProperty(property_id) + end) + if not success then + return nil, "provider_error" + end + if property == nil then + return nil, "property_not_found" + end + if type(property) ~= "table" then + Bridge.Debug("error", "[sky_phone] %s:GetProperty returned invalid data.", resource_name) + return nil, "provider_error" + end + return property +end + +local function boolean_export(export_name, callback) + local success, value = provider_call(export_name, callback) + if not success then + return nil, "provider_error" + end + if type(value) ~= "boolean" then + Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name) + return nil, "provider_error" + end + return value +end + +local function collect_catalog(catalog, properties) + local direct_id, direct_key = property_identifier(properties) + if direct_id then + catalog[direct_key] = { id = direct_id, property = properties } + return + end + + local is_array = #properties > 0 + for key, property in pairs(properties) do + if type(property) == "table" then + local property_id, id_key = property_identifier(property, is_array and nil or key) + if property_id then + catalog[id_key] = { + id = property_id, + property = property, + } + end + end + end +end + +local function collect_player_properties(properties, catalog) + local direct = {} + local direct_id, direct_key = property_identifier(properties) + if direct_id then + direct[direct_key] = direct_id + if catalog then + catalog[direct_key] = { id = direct_id, property = properties } + end + return direct + end + + local is_array = #properties > 0 + for key, value in pairs(properties) do + local property = type(value) == "table" and value or nil + local fallback = property and (is_array and nil or key) + or (is_array and value or key) + local property_id, id_key = property_identifier(property, fallback) + if property_id then + direct[id_key] = property_id + if catalog and property then + catalog[id_key] = { + id = property_id, + property = property, + } + end + end + end + return direct +end + +local function identifiers_match(value, identifier) + return identifier ~= nil + and value ~= nil + and tostring(value) ~= "" + and tostring(value) == tostring(identifier) +end + +local function property_access(source, property, property_id, id_key, direct, identifier) + if identifiers_match(property.owner, identifier) then + return "owner" + end + if identifiers_match(property.renter, identifier) or direct[id_key] then + return "keyholder" + end + if not identifier then + return nil + end + + local has_keys, error_code = boolean_export("HasKeys", function() + return exports[resource_name]:HasKeys(source, identifier, property_id) + end) + if has_keys == nil then + return nil, error_code + end + if has_keys then + return "keyholder" + end + + local has_permissions + has_permissions, error_code = boolean_export("HasAnyPermission", function() + return exports[resource_name]:HasAnyPermission(property_id, identifier) + end) + if has_permissions == nil then + return nil, error_code + end + return has_permissions and "keyholder" or nil +end + +local function property_metadata(property) + return decode_table(property.metadata) or {} +end + +local function property_entrance(property, building_cache) + local object_id = property.object_id + if object_id ~= nil and tostring(object_id) ~= "" then + local normalized_object_id, object_key = normalize_property_id(object_id) + if normalized_object_id then + local building = nil + local cache_hit = building_cache and building_cache[object_key] ~= nil + if cache_hit then + building = building_cache[object_key] + else + local object, error_code = get_property(normalized_object_id) + if not object and error_code ~= "property_not_found" then + return nil, error_code + end + building = object or false + if building_cache then + building_cache[object_key] = building + end + end + if type(building) == "table" and building.type == "building" then + local coords = normalized_coords(property_metadata(building).enter) + if coords then + return coords + end + end + end + end + + local metadata = property_metadata(property) + return normalized_coords(metadata.enter) or normalized_coords(metadata.menu) +end + +local function property_name(property, id_key) + if type(property.name) == "string" and property.name ~= "" then + return property.name + end + if type(property.address) == "string" and property.address ~= "" then + return property.address + end + return ("Property %s"):format(id_key) +end + +local function normalized_property(property, id_key, access, coords) + local metadata = property_metadata(property) + return { + id = ("%s:%s"):format(provider_name, id_key), + providerId = id_key, + name = property_name(property, id_key), + access = access, + locked = metadata.locked == true, + entrance = coords, + capabilities = { + lock = false, + keys = false, + waypoint = true, + cctv = false, + garageStatus = false, + }, + cctv = { enabled = false }, + garage = nil, + keys = nil, + } +end + +local function resolve_property(source, data) + if type(data) ~= "table" or type(data.propertyId) ~= "string" then + return nil, nil, nil, nil, "invalid_request" + end + local raw_id = data.propertyId:match("^vms:(.+)$") + local property_id, id_key = normalize_property_id(raw_id) + if not property_id then + return nil, nil, nil, nil, "invalid_property" + end + + local property, error_code = get_property(property_id) + if not property then + return nil, nil, nil, nil, error_code + end + local player_properties + player_properties, error_code = get_player_properties(source) + if not player_properties then + return nil, nil, nil, nil, error_code + end + local direct = collect_player_properties(player_properties) + local identifier = Bridge.Framework.GetIdentifier(source) + local access + access, error_code = property_access( + source, + property, + property_id, + id_key, + direct, + identifier + ) + if error_code then + return nil, nil, nil, nil, error_code + end + if not access then + return nil, nil, nil, nil, "property_access_denied" + end + local coords + coords, error_code = property_entrance(property) + if not coords then + return nil, nil, nil, nil, error_code or "invalid_coordinates" + end + return property, property_id, id_key, coords +end + +Bridge.Housing.RegisterProvider(provider_name, { + resource_name = resource_name, + is_available = function() + return GetResourceState(resource_name) == "started" + end, + get_overview = function(source) + local properties, error_code = get_all_properties() + if not properties then + return nil, error_code + end + local player_properties + player_properties, error_code = get_player_properties(source) + if not player_properties then + return nil, error_code + end + + local catalog = {} + collect_catalog(catalog, properties) + local direct = collect_player_properties(player_properties, catalog) + for id_key, property_id in pairs(direct) do + if not catalog[id_key] then + local property + property, error_code = get_property(property_id) + if not property then + return nil, error_code + end + catalog[id_key] = { id = property_id, property = property } + end + end + + local identifier = Bridge.Framework.GetIdentifier(source) + local building_cache = {} + local result = {} + for id_key, entry in pairs(catalog) do + local access + access, error_code = property_access( + source, + entry.property, + entry.id, + id_key, + direct, + identifier + ) + if error_code then + return nil, error_code + end + if access then + local coords + coords, error_code = property_entrance(entry.property, building_cache) + if error_code then + return nil, error_code + end + if coords then + result[#result + 1] = normalized_property( + entry.property, + id_key, + access, + coords + ) + else + Bridge.Debug( + "debug", + "[sky_phone] Ignored VMS property '%s' because no valid entrance was found.", + id_key + ) + end + end + end + + table.sort(result, function(left, right) + if left.access ~= right.access then + return left.access == "owner" + end + return string.lower(left.name) < string.lower(right.name) + end) + + local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50)) + local limited = {} + for index = 1, math.min(#result, maximum) do + limited[index] = result[index] + end + return limited + end, + prepare = function(source, action, data) + local property, property_id, id_key, coords, error_code = + resolve_property(source, data) + if not property then + return nil, error_code + end + if action == "set_waypoint" then + return { coords = coords } + end + if action == "key_candidates" or action == "grant_key" or action == "revoke_key" + or action == "toggle_lock" + then + return nil, "capability_unavailable" + end + if action == "open_cctv" then + return nil, "cctv_unavailable" + end + return nil, "invalid_action" + end, +}) diff --git a/sky_phone/source/bridge/shared.lua b/sky_phone/source/bridge/shared.lua index 04d375b..50a40bd 100644 --- a/sky_phone/source/bridge/shared.lua +++ b/sky_phone/source/bridge/shared.lua @@ -5,8 +5,38 @@ Bridge.Database = Bridge.Database or {} Bridge.Framework = Bridge.Framework or {} Bridge.Inventory = Bridge.Inventory or {} Bridge.Radio = Bridge.Radio or {} +Bridge.Normalize = Bridge.Normalize or {} Bridge.Speaker = Bridge.Speaker or {} +function Bridge.Normalize.FiniteNumber(value) + local number = tonumber(value) + if not number or number ~= number or number == math.huge or number == -math.huge then + return nil + end + return number +end + +function Bridge.Normalize.Coordinates(value) + if value == nil then + return nil + end + + local success, x, y, z = pcall(function() + return value.x or value[1], value.y or value[2], value.z or value[3] + end) + if not success then + return nil + end + + x = Bridge.Normalize.FiniteNumber(x) + y = Bridge.Normalize.FiniteNumber(y) + z = Bridge.Normalize.FiniteNumber(z) + if not x or not y or not z then + return nil + end + return { x = x, y = y, z = z } +end + function Bridge.Speaker.IsEnabled() return not Config.Speaker or Config.Speaker.Enabled ~= false end diff --git a/sky_phone/source/client/housing.lua b/sky_phone/source/client/housing.lua index 93f3b5b..5dcca88 100644 --- a/sky_phone/source/client/housing.lua +++ b/sky_phone/source/client/housing.lua @@ -169,6 +169,17 @@ RegisterNUICallback("housing:overview", function(data, cb) return end local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {}) + if type(result) == "table" and result.success and type(result.data) == "table" then + local properties, error_code = Bridge.Housing.EnrichOverview( + result.data.provider, + result.data.properties + ) + if not properties then + cb({ success = false, error = error_code or "provider_error" }) + return + end + result.data.properties = properties + end cb(type(result) == "table" and result or { success = false, error = "request_failed" }) end) @@ -211,11 +222,24 @@ RegisterNUICallback("housing:command", function(data, cb) end if data.action == "set_waypoint" then local coords = prepared.data.coords - if type(coords) ~= "table" or not tonumber(coords.x) or not tonumber(coords.y) then + if coords == nil then + local success, error_code = Bridge.Housing.Execute( + prepared.data.provider, + data.action, + prepared.data + ) + cb(success and { success = true } + or { success = false, error = error_code or "invalid_coordinates" }) + return + end + + local x = type(coords) == "table" and camera_number(coords.x) or nil + local y = type(coords) == "table" and camera_number(coords.y) or nil + if not x or not y then cb({ success = false, error = "invalid_coordinates" }) return end - SetNewWaypoint(tonumber(coords.x) + 0.0, tonumber(coords.y) + 0.0) + SetNewWaypoint(x + 0.0, y + 0.0) cb({ success = true }) return end