diff --git a/app/components/device/new/location-info.tsx b/app/components/device/new/location-info.tsx index 6587ccd9..bf65e64f 100644 --- a/app/components/device/new/location-info.tsx +++ b/app/components/device/new/location-info.tsx @@ -11,7 +11,14 @@ import { import { Input } from '@/components/ui/input' import { Label } from '~/components/ui/label' import { BaseMap } from '~/components/base-map' -import { LOCATION_LIMITS, isValidLocation } from '~/lib/location' +import { + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, + LOCATION_LIMITS, + LOCATION_PRIVACY_DISTANCE_PRESETS, + isValidLocation, + type LocationPrivacyData, +} from '~/lib/location' export function LocationStep() { const mapRef = useRef(null) @@ -24,6 +31,13 @@ export function LocationStep() { const { t } = useTranslation('newdevice') const savedLatitude = watch('latitude') const savedLongitude = watch('longitude') + const locationPrivacy = watch('locationPrivacy') ?? 'masked' + const locationPrivacyMinDistanceMeters = + watch('locationPrivacyMinDistanceMeters') ?? + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS + const locationPrivacyRadiusMeters = + watch('locationPrivacyRadiusMeters') ?? + DEFAULT_LOCATION_PRIVACY_RADIUS_METERS const [marker, setMarker] = useState<{ latitude: number | string @@ -33,6 +47,33 @@ export function LocationStep() { longitude: savedLongitude || '', }) + useEffect(() => { + if (!locationPrivacy) { + setValue('locationPrivacy', 'masked', { shouldValidate: true }) + } + if (!locationPrivacyMinDistanceMeters) { + setValue( + 'locationPrivacyMinDistanceMeters', + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + { + shouldValidate: true, + }, + ) + } + if (!locationPrivacyRadiusMeters) { + setValue( + 'locationPrivacyRadiusMeters', + DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, + { shouldValidate: true }, + ) + } + }, [ + locationPrivacy, + locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters, + setValue, + ]) + useEffect(() => { if (savedLatitude !== undefined && savedLongitude !== undefined) { setMarker({ @@ -42,6 +83,43 @@ export function LocationStep() { } }, [savedLatitude, savedLongitude]) + const onLocationPrivacyChange = ( + event: React.ChangeEvent, + ) => { + setValue( + 'locationPrivacy', + event.target.value === 'exact' ? 'exact' : 'masked', + { shouldValidate: true }, + ) + } + + const onLocationPrivacyPresetChange = ( + event: React.ChangeEvent, + ) => { + const [minDistance, maxDistance] = event.target.value.split(':').map(Number) + + const preset = LOCATION_PRIVACY_DISTANCE_PRESETS.find( + (candidate) => + candidate.min === minDistance && candidate.max === maxDistance, + ) + + if (!preset) return + + setValue( + 'locationPrivacyMinDistanceMeters', + preset.min as LocationPrivacyData['locationPrivacyMinDistanceMeters'], + { shouldValidate: true }, + ) + setValue( + 'locationPrivacyRadiusMeters', + preset.max as LocationPrivacyData['locationPrivacyRadiusMeters'], + { shouldValidate: true }, + ) + } + + const formatDistance = (meters: number) => + meters >= 1000 ? `${meters / 1000} km` : `${meters} m` + const handleLatitudeChange = (e: React.ChangeEvent) => { const value = e.target.value.trim() const parsedValue = parseFloat(value) @@ -136,7 +214,7 @@ export function LocationStep() { -
+
) : null}
+ +
+
+ + {t('public_location')} + + +
+ + + +
+
+ +
+ + + + + + {errors.locationPrivacyMinDistanceMeters?.message ? ( +

+ {String(errors.locationPrivacyMinDistanceMeters.message)} +

+ ) : null} +
+
) diff --git a/app/components/device/new/new-device-stepper.tsx b/app/components/device/new/new-device-stepper.tsx index 05dc5bc3..322b6204 100644 --- a/app/components/device/new/new-device-stepper.tsx +++ b/app/components/device/new/new-device-stepper.tsx @@ -29,7 +29,12 @@ import { import { useToast } from '~/components/ui/use-toast' import { DeviceModelEnum } from '~/db/schema/enum' import { type loader } from '~/routes/device.new' -import { locationSchema, type LocationData } from '~/lib/location' +import { + locationPrivacySchema, + locationSchema, + type LocationData, + type LocationPrivacyData, +} from '~/lib/location' import { generalInfoSchema, type GeneralInfoData } from '~/lib/device-general' const deviceSchema = z.object({ @@ -46,10 +51,11 @@ const sensorsSchema = z.object({ }) const advancedSchema = z.record(z.string(), z.any()) +const deviceLocationSchema = locationSchema.and(locationPrivacySchema) const formSchema = z.union([ generalInfoSchema, - locationSchema, + deviceLocationSchema, deviceSchema, sensorsSchema, advancedSchema, @@ -67,7 +73,7 @@ export const Stepper = defineStepper( id: 'location', label: 'location', infoKey: 'location_info_text', - schema: locationSchema, + schema: deviceLocationSchema, index: 1, }, { @@ -106,7 +112,7 @@ type AdvancedData = z.infer type FormData = | GeneralInfoData - | LocationData + | (LocationData & LocationPrivacyData) | DeviceData | SensorData | AdvancedData diff --git a/app/components/device/new/summary-info.tsx b/app/components/device/new/summary-info.tsx index 6213f14c..000fd5fa 100644 --- a/app/components/device/new/summary-info.tsx +++ b/app/components/device/new/summary-info.tsx @@ -7,6 +7,13 @@ export function SummaryInfo() { const { getValues } = useFormContext() const formData = getValues() const { t } = useTranslation('newdevice') + const publicLocationValue = + formData.locationPrivacy === 'exact' + ? t('exact_location') + : t('approximate_location_summary', { + min: formData.locationPrivacyMinDistanceMeters, + max: formData.locationPrivacyRadiusMeters, + }) const sections = [ { @@ -31,6 +38,10 @@ export function SummaryInfo() { label: 'longitude', value: parseFloat(formData.longitude).toFixed(4), }, + { + label: 'public_location', + value: publicLocationValue, + }, ], }, { diff --git a/app/db/drizzle/0046_chemical_maelstrom.sql b/app/db/drizzle/0046_chemical_maelstrom.sql new file mode 100644 index 00000000..16525d80 --- /dev/null +++ b/app/db/drizzle/0046_chemical_maelstrom.sql @@ -0,0 +1,4 @@ +ALTER TABLE "device" ADD COLUMN "location_privacy" text DEFAULT 'masked' NOT NULL;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "location_privacy_min_distance_meters" integer DEFAULT 20 NOT NULL;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "location_privacy_radius_meters" integer DEFAULT 50 NOT NULL;--> statement-breakpoint +ALTER TABLE "device" ADD COLUMN "location_privacy_method" text DEFAULT 'stable-donut-displacement-v1' NOT NULL; diff --git a/app/db/drizzle/meta/0046_snapshot.json b/app/db/drizzle/meta/0046_snapshot.json new file mode 100644 index 00000000..a773a16d --- /dev/null +++ b/app/db/drizzle/meta/0046_snapshot.json @@ -0,0 +1,1694 @@ +{ + "id": "79e11048-8a45-4a7e-a63a-45f87839d601", + "prevId": "ef9b819e-831a-4fac-8183-8eb681b40a71", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "ARRAY[]::text[]" + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_auth": { + "name": "use_auth", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "exposure", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "model": { + "name": "model", + "type": "model", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'custom'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "orphaned_at": { + "name": "orphaned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "location_privacy": { + "name": "location_privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'masked'" + }, + "location_privacy_min_distance_meters": { + "name": "location_privacy_min_distance_meters", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "location_privacy_radius_meters": { + "name": "location_privacy_radius_meters", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "location_privacy_method": { + "name": "location_privacy_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stable-donut-displacement-v1'" + }, + "sensor_wiki_model": { + "name": "sensor_wiki_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "device_user_id_user_id_fk": { + "name": "device_user_id_user_id_fk", + "tableFrom": "device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_to_location": { + "name": "device_to_location", + "schema": "", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_to_location_device_id_device_id_fk": { + "name": "device_to_location_device_id_device_id_fk", + "tableFrom": "device_to_location", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "device_to_location_location_id_location_id_fk": { + "name": "device_to_location_location_id_location_id_fk", + "tableFrom": "device_to_location", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_to_location_device_id_location_id_time_pk": { + "name": "device_to_location_device_id_location_id_time_pk", + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "uniqueConstraints": { + "device_to_location_device_id_location_id_time_unique": { + "name": "device_to_location_device_id_location_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "location_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.measurement": { + "name": "measurement", + "schema": "", + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "measurement_location_id_location_id_fk": { + "name": "measurement_location_id_location_id_fk", + "tableFrom": "measurement", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "measurement_sensor_id_time_unique": { + "name": "measurement_sensor_id_time_unique", + "nullsNotDistinct": false, + "columns": [ + "sensor_id", + "time" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password": { + "name": "password", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "password_user_id_user_id_fk": { + "name": "password_user_id_user_id_fk", + "tableFrom": "password", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "home_latitude": { + "name": "home_latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "home_longitude": { + "name": "home_longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "home_zoom": { + "name": "home_zoom", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_user_id_unique": { + "name": "profile_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_image": { + "name": "profile_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blob": { + "name": "blob", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "profile_image_profile_id_profile_id_fk": { + "name": "profile_image_profile_id_profile_id_fk", + "tableFrom": "profile_image", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensor": { + "name": "sensor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensor_wiki_type": { + "name": "sensor_wiki_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_phenomenon": { + "name": "sensor_wiki_phenomenon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sensor_wiki_unit": { + "name": "sensor_wiki_unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMeasurement": { + "name": "lastMeasurement", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "sensor_device_id_device_id_fk": { + "name": "sensor_device_id_device_id_fk", + "tableFrom": "sensor", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unconfirmed_email": { + "name": "unconfirmed_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_preference": { + "name": "theme_preference", + "type": "theme_preference", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'en_US'" + }, + "email_is_confirmed": { + "name": "email_is_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "newsletter_opt_in": { + "name": "newsletter_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_tos_version_id": { + "name": "accepted_tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_tos_at": { + "name": "accepted_tos_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_accepted_tos_version_id_tos_version_id_fk": { + "name": "user_accepted_tos_version_id_tos_version_id_fk", + "tableFrom": "user", + "tableTo": "tos_version", + "columnsFrom": [ + "accepted_tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_name_unique": { + "name": "user_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_unconfirmed_email_unique": { + "name": "user_unconfirmed_email_unique", + "nullsNotDistinct": false, + "columns": [ + "unconfirmed_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "location_index": { + "name": "location_index", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "location_location_unique": { + "name": "location_location_unique", + "nullsNotDistinct": false, + "columns": [ + "location" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.log_entry": { + "name": "log_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_token_user_id_user_id_fk": { + "name": "refresh_token_user_id_user_id_fk", + "tableFrom": "refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_revocation": { + "name": "token_revocation", + "schema": "", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "box_id": { + "name": "box_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "claim_expires_at_idx": { + "name": "claim_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "claim_box_id_device_id_fk": { + "name": "claim_box_id_device_id_fk", + "tableFrom": "claim", + "tableTo": "device", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_box_id": { + "name": "unique_box_id", + "nullsNotDistinct": false, + "columns": [ + "box_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_key": { + "name": "service_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_slug_unique": { + "name": "integration_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_user_state": { + "name": "tos_user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tos_version_id": { + "name": "tos_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tos_user_state_user_idx": { + "name": "tos_user_state_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tos_user_state_user_id_user_id_fk": { + "name": "tos_user_state_user_id_user_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tos_user_state_tos_version_id_tos_version_id_fk": { + "name": "tos_user_state_tos_version_id_tos_version_id_fk", + "tableFrom": "tos_user_state", + "tableTo": "tos_version", + "columnsFrom": [ + "tos_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tos_user_state_user_id_tos_version_id_pk": { + "name": "tos_user_state_user_id_tos_version_id_pk", + "columns": [ + "user_id", + "tos_version_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tos_version": { + "name": "tos_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accept_by": { + "name": "accept_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tos_version_effective_from_idx": { + "name": "tos_version_effective_from_idx", + "columns": [ + { + "expression": "effective_from", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tos_version_accept_by_idx": { + "name": "tos_version_accept_by_idx", + "columns": [ + { + "expression": "accept_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tos_version_version_unique": { + "name": "tos_version_version_unique", + "nullsNotDistinct": false, + "columns": [ + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_token": { + "name": "action_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "action_token_user_purpose_uq": { + "name": "action_token_user_purpose_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purpose", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_token_expires_at_idx": { + "name": "action_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "action_token_user_id_user_id_fk": { + "name": "action_token_user_id_user_id_fk", + "tableFrom": "action_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "action_token_token_hash_unique": { + "name": "action_token_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.exposure": { + "name": "exposure", + "schema": "public", + "values": [ + "indoor", + "outdoor", + "mobile", + "unknown" + ] + }, + "public.model": { + "name": "model", + "schema": "public", + "values": [ + "homeV2Lora", + "homeV2Ethernet", + "homeV2Wifi", + "homeEthernet", + "homeWifi", + "homeEthernetFeinstaub", + "homeWifiFeinstaub", + "luftdaten_sds011", + "luftdaten_sds011_dht11", + "luftdaten_sds011_dht22", + "luftdaten_sds011_bmp180", + "luftdaten_sds011_bme280", + "hackair_home_v2", + "senseBox:Edu", + "luftdaten.info", + "custom" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "active", + "inactive", + "old" + ] + }, + "public.theme_preference": { + "name": "theme_preference", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.measurement_10min": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_10min", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1day": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1day", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1hour": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1hour", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1month": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1month", + "schema": "public", + "isExisting": true, + "materialized": true + }, + "public.measurement_1year": { + "columns": { + "sensor_id": { + "name": "sensor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "avg_value": { + "name": "avg_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "total_values": { + "name": "total_values", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_value": { + "name": "max_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "name": "measurement_1year", + "schema": "public", + "isExisting": true, + "materialized": true + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/app/db/drizzle/meta/_journal.json b/app/db/drizzle/meta/_journal.json index 76c934ac..95643d1e 100644 --- a/app/db/drizzle/meta/_journal.json +++ b/app/db/drizzle/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1782121660841, "tag": "0045_blushing_chameleon", "breakpoints": true + }, + { + "idx": 46, + "version": "7", + "when": 1783597984499, + "tag": "0046_chemical_maelstrom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/db/models/device.server.ts b/app/db/models/device.server.ts index ed1a3e68..ab859920 100644 --- a/app/db/models/device.server.ts +++ b/app/db/models/device.server.ts @@ -38,6 +38,7 @@ import { messages as NewSenseboxDeviceMessages } from '~/emails/new-device-sense import { createDeviceApiKey } from '~/lib/jwt' import { sendMail } from '~/lib/mail.server' import { getSensorsForModel } from '~/lib/model-definitions' +import { getPublicLocation } from '~/lib/geomasking.server' const BASE_DEVICE_COLUMNS = { id: true, @@ -51,6 +52,10 @@ const BASE_DEVICE_COLUMNS = { model: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyMinDistanceMeters: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, status: true, createdAt: true, updatedAt: true, @@ -186,6 +191,10 @@ export function getUserDevice({ id, userId }: Pick) { updatedAt: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyMinDistanceMeters: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, userId: true, }, }) @@ -226,6 +235,10 @@ export function getDeviceWithoutSensors({ id }: Pick) { updatedAt: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyMinDistanceMeters: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, userId: true, useAuth: true, model: true, @@ -242,7 +255,18 @@ export async function updateDeviceLocation({ id, latitude, longitude, -}: Pick) { + locationPrivacy, + locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters, +}: Pick & + Partial< + Pick< + Device, + | 'locationPrivacy' + | 'locationPrivacyMinDistanceMeters' + | 'locationPrivacyRadiusMeters' + > + >) { const [existingDevice] = await drizzleClient .select() .from(device) @@ -257,7 +281,18 @@ export async function updateDeviceLocation({ return drizzleClient .update(device) - .set({ latitude, longitude, updatedAt: sql`NOW()` }) + .set({ + latitude, + longitude, + ...(locationPrivacy !== undefined && { locationPrivacy }), + ...(locationPrivacyMinDistanceMeters !== undefined && { + locationPrivacyMinDistanceMeters, + }), + ...(locationPrivacyRadiusMeters !== undefined && { + locationPrivacyRadiusMeters, + }), + updatedAt: sql`NOW()`, + }) .where(eq(device.id, id)) } @@ -272,6 +307,9 @@ export type UpdateDeviceArgs = { model?: string useAuth?: boolean location?: { lat: number; lng: number; height?: number } + locationPrivacy?: string + locationPrivacyMinDistanceMeters?: number + locationPrivacyRadiusMeters?: number sensors?: SensorUpdateArgs[] } @@ -313,6 +351,9 @@ export async function updateDevice( 'model', 'useAuth', 'link', + 'locationPrivacy', + 'locationPrivacyMinDistanceMeters', + 'locationPrivacyRadiusMeters', ] for (const field of updatableFields) { @@ -548,6 +589,10 @@ export async function getDevices(format: DevicesFormat = 'json') { name: true, latitude: true, longitude: true, + locationPrivacy: true, + locationPrivacyMinDistanceMeters: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, exposure: true, status: true, createdAt: true, @@ -562,8 +607,15 @@ export async function getDevices(format: DevicesFormat = 'json') { } for (const device of devices) { - const coordinates = [device.longitude, device.latitude] - const feature = point(coordinates, device) + const publicLocation = getPublicLocation(device) + const publicDevice = { + ...device, + latitude: publicLocation.latitude, + longitude: publicLocation.longitude, + locationDisclosure: publicLocation.disclosure, + } + const coordinates = [publicLocation.longitude, publicLocation.latitude] + const feature = point(coordinates, publicDevice) geojson.features.push(feature) } @@ -676,8 +728,15 @@ export async function getDevicesWithSensors(options?: { ) for (const result of resultArray) { - const coordinates = [result.device.longitude, result.device.latitude] - const feature = point(coordinates, result.device) + const publicLocation = getPublicLocation(result.device) + const publicDevice = { + ...result.device, + latitude: publicLocation.latitude, + longitude: publicLocation.longitude, + locationDisclosure: publicLocation.disclosure, + } + const coordinates = [publicLocation.longitude, publicLocation.latitude] + const feature = point(coordinates, publicDevice) geojson.features.push(feature) } @@ -810,6 +869,10 @@ const MINIMAL_COLUMNS = { exposure: true, longitude: true, latitude: true, + locationPrivacy: true, + locationPrivacyMinDistanceMeters: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, } const DEFAULT_COLUMNS = { @@ -825,6 +888,10 @@ const DEFAULT_COLUMNS = { updatedAt: true, longitude: true, latitude: true, + locationPrivacy: true, + locationPrivacyMinDistanceMeters: true, + locationPrivacyRadiusMeters: true, + locationPrivacyMethod: true, } export async function findDevices( @@ -922,6 +989,10 @@ export async function createDevice(deviceData: any, userId: string) { : null, latitude: deviceData.latitude, longitude: deviceData.longitude, + locationPrivacy: deviceData.locationPrivacy, + locationPrivacyMinDistanceMeters: + deviceData.locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters: deviceData.locationPrivacyRadiusMeters, }) .returning() diff --git a/app/db/schema/device.ts b/app/db/schema/device.ts index 022f7708..e18e529e 100644 --- a/app/db/schema/device.ts +++ b/app/db/schema/device.ts @@ -15,6 +15,7 @@ import { unique, date, bigint, + integer, } from 'drizzle-orm/pg-core' import { DeviceExposureEnum, DeviceModelEnum, DeviceStatusEnum } from './enum' import { location } from './location' @@ -51,6 +52,18 @@ export const device = pgTable('device', { expiresAt: date('expires_at', { mode: 'date' }), latitude: doublePrecision('latitude').notNull(), longitude: doublePrecision('longitude').notNull(), + locationPrivacy: text('location_privacy').default('masked').notNull(), + locationPrivacyMinDistanceMeters: integer( + 'location_privacy_min_distance_meters', + ) + .default(20) + .notNull(), + locationPrivacyRadiusMeters: integer('location_privacy_radius_meters') + .default(50) + .notNull(), + locationPrivacyMethod: text('location_privacy_method') + .default('stable-donut-displacement-v1') + .notNull(), sensorWikiModel: text('sensor_wiki_model'), userId: text('user_id') .notNull() diff --git a/app/lib/api-schemas/devices.ts b/app/lib/api-schemas/devices.ts index 66dc3fb2..b4745cae 100644 --- a/app/lib/api-schemas/devices.ts +++ b/app/lib/api-schemas/devices.ts @@ -1,54 +1,101 @@ import { z } from 'zod' +import { + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, + LOCATION_PRIVACY_MIN_DISTANCE_VALUES, + LOCATION_PRIVACY_RADIUS_VALUES, + LOCATION_PRIVACY_VALUES, +} from '~/lib/location' -export const CreateDeviceSchema = z.object({ - // public API request shape - name: z.string().min(1).max(100), - description: z - .string() - .max(5000, 'Description should not exceed 5000 characters') - .optional() - .nullable(), - exposure: z - .enum(['indoor', 'outdoor', 'mobile', 'unknown']) - .optional() - .default('unknown'), - location: z - .union([ - z.array(z.number()).min(2).max(3), - z.object({ - lng: z.number(), - lat: z.number(), - height: z.number().optional(), - }), - ]) - .transform((loc) => { - if (Array.isArray(loc)) return loc - return [loc.lng, loc.lat, ...(loc.height ? [loc.height] : [])] - }), - grouptag: z.array(z.string()).optional().default([]), - model: z - .enum([ - 'homeV2Lora', - 'homeV2Ethernet', - 'homeV2Wifi', - 'senseBox:Edu', - 'luftdaten.info', - 'custom', - ]) - .optional() - .default('custom'), - sensors: z - .array( - z.object({ - icon: z.string().optional(), - title: z.string().min(1), - unit: z.string().min(1), - sensorType: z.string().min(1), +export const CreateDeviceSchema = z + .object({ + // public API request shape + name: z.string().min(1).max(100), + description: z + .string() + .max(5000, 'Description should not exceed 5000 characters') + .optional() + .nullable(), + exposure: z + .enum(['indoor', 'outdoor', 'mobile', 'unknown']) + .optional() + .default('unknown'), + location: z + .union([ + z.array(z.number()).min(2).max(3), + z.object({ + lng: z.number(), + lat: z.number(), + height: z.number().optional(), + }), + ]) + .transform((loc) => { + if (Array.isArray(loc)) return loc + return [loc.lng, loc.lat, ...(loc.height ? [loc.height] : [])] }), - ) - .optional() - .default([]), -}) + locationPrivacy: z + .enum(LOCATION_PRIVACY_VALUES) + .optional() + .default('masked'), + locationPrivacyMinDistanceMeters: z + .number() + .refine( + ( + value, + ): value is (typeof LOCATION_PRIVACY_MIN_DISTANCE_VALUES)[number] => + LOCATION_PRIVACY_MIN_DISTANCE_VALUES.includes( + value as (typeof LOCATION_PRIVACY_MIN_DISTANCE_VALUES)[number], + ), + 'Location privacy minimum distance is invalid', + ) + .optional() + .default(DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS), + locationPrivacyRadiusMeters: z + .number() + .refine( + (value): value is (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number] => + LOCATION_PRIVACY_RADIUS_VALUES.includes( + value as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ), + 'Location privacy radius is invalid', + ) + .optional() + .default(DEFAULT_LOCATION_PRIVACY_RADIUS_METERS), + grouptag: z.array(z.string()).optional().default([]), + model: z + .enum([ + 'homeV2Lora', + 'homeV2Ethernet', + 'homeV2Wifi', + 'senseBox:Edu', + 'luftdaten.info', + 'custom', + ]) + .optional() + .default('custom'), + sensors: z + .array( + z.object({ + icon: z.string().optional(), + title: z.string().min(1), + unit: z.string().min(1), + sensorType: z.string().min(1), + }), + ) + .optional() + .default([]), + }) + .refine( + (value) => + value.locationPrivacy === 'exact' || + value.locationPrivacyMinDistanceMeters < + value.locationPrivacyRadiusMeters, + { + message: + 'Location privacy minimum distance must be smaller than the maximum radius', + path: ['locationPrivacyMinDistanceMeters'], + }, + ) export const DevicesQuerySchema = z.object({ format: z diff --git a/app/lib/device-transform.ts b/app/lib/device-transform.ts index f67bba1f..54b76838 100644 --- a/app/lib/device-transform.ts +++ b/app/lib/device-transform.ts @@ -1,8 +1,12 @@ import { type Device, type Sensor } from '~/db/schema' +import { + getPublicLocation, + type LocationDisclosure, +} from '~/lib/geomasking.server' import { toIsoString } from '~/utils' export type DeviceWithSensors = Device & { - sensors: Sensor[] + sensors?: Sensor[] } export type TransformedDevice = { @@ -17,6 +21,11 @@ export type TransformedDevice = { model: string | null latitude: number longitude: number + locationPrivacy?: string | null + locationPrivacyMinDistanceMeters?: number | null + locationPrivacyRadiusMeters?: number | null + locationPrivacyMethod?: string | null + locationDisclosure: LocationDisclosure useAuth: boolean | null access_token: string | null public: boolean | null @@ -66,15 +75,33 @@ export type TransformedDevice = { */ export function transformDeviceToApiFormat( box: DeviceWithSensors, + options: { includeExactLocation?: boolean } = {}, ): TransformedDevice { const { id, tags, sensors, apiKey, ...rest } = box const timestamp = box.updatedAt.toISOString() - const coordinates = [box.longitude, box.latitude] + // Public API responses use the privacy-aware location by default. + const publicLocation = options.includeExactLocation + ? { + latitude: box.latitude, + longitude: box.longitude, + disclosure: { + mode: 'exact' as const, + accuracyMeters: 0 as const, + minDistanceMeters: 0 as const, + maxDistanceMeters: 0 as const, + method: null, + }, + } + : getPublicLocation(box) + const coordinates = [publicLocation.longitude, publicLocation.latitude] return { _id: id, grouptag: tags || [], ...rest, + latitude: publicLocation.latitude, + longitude: publicLocation.longitude, + locationDisclosure: publicLocation.disclosure, createdAt: toIsoString(box.createdAt)!, updatedAt: toIsoString(box.updatedAt)!, expiresAt: toIsoString(box.expiresAt), diff --git a/app/lib/geomasking.server.ts b/app/lib/geomasking.server.ts new file mode 100644 index 00000000..3452c605 --- /dev/null +++ b/app/lib/geomasking.server.ts @@ -0,0 +1,154 @@ +import { createHmac } from 'node:crypto' +import { + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, + LOCATION_PRIVACY_METHOD, +} from '~/lib/location' + +export type LocationDisclosure = + | { + mode: 'exact' + accuracyMeters: 0 + minDistanceMeters: 0 + maxDistanceMeters: 0 + method: null + } + | { + mode: 'masked' + accuracyMeters: number + minDistanceMeters: number + maxDistanceMeters: number + method: typeof LOCATION_PRIVACY_METHOD + } + +export type PublicLocation = { + latitude: number + longitude: number + disclosure: LocationDisclosure +} + +export type LocationPrivacyDevice = { + id: string + latitude: number + longitude: number + locationPrivacy?: string | null + locationPrivacyMinDistanceMeters?: number | null + locationPrivacyRadiusMeters?: number | null + locationPrivacyMethod?: string | null +} + +const EARTH_RADIUS_METERS = 6371008.8 + +// Derive repeatable pseudo-random values from stable device/privacy inputs. +function randomUnitValues(seed: string) { + const digest = createHmac( + 'sha256', + process.env.SESSION_SECRET || 'openSenseMap-location-privacy', + ) + .update(seed) + .digest() + + const first = digest.readUInt32BE(0) / 0xffffffff + const second = digest.readUInt32BE(4) / 0xffffffff + + return [first, second] as const +} + +// Move a latitude/longitude by a distance and bearing on a spherical earth. +function destinationPoint( + latitude: number, + longitude: number, + distanceMeters: number, + bearingRadians: number, +) { + const angularDistance = distanceMeters / EARTH_RADIUS_METERS + const latitudeRadians = (latitude * Math.PI) / 180 + const longitudeRadians = (longitude * Math.PI) / 180 + + const destinationLatitude = Math.asin( + Math.sin(latitudeRadians) * Math.cos(angularDistance) + + Math.cos(latitudeRadians) * + Math.sin(angularDistance) * + Math.cos(bearingRadians), + ) + + const destinationLongitude = + longitudeRadians + + Math.atan2( + Math.sin(bearingRadians) * + Math.sin(angularDistance) * + Math.cos(latitudeRadians), + Math.cos(angularDistance) - + Math.sin(latitudeRadians) * Math.sin(destinationLatitude), + ) + + return { + // Six decimals are roughly decimeter precision, enough to keep output + // stable while avoiding noisy floating point tails. + latitude: Number(((destinationLatitude * 180) / Math.PI).toFixed(6)), + longitude: Number( + // Normalize longitude back into the conventional [-180, 180) range. + ((((destinationLongitude * 180) / Math.PI + 540) % 360) - 180).toFixed(6), + ), + } +} + +export function getPublicLocation( + device: LocationPrivacyDevice, +): PublicLocation { + if (device.locationPrivacy !== 'masked') { + return { + latitude: device.latitude, + longitude: device.longitude, + disclosure: { + mode: 'exact', + accuracyMeters: 0, + minDistanceMeters: 0, + maxDistanceMeters: 0, + method: null, + }, + } + } + + const maxDistanceMeters = + device.locationPrivacyRadiusMeters ?? DEFAULT_LOCATION_PRIVACY_RADIUS_METERS + const configuredMinDistanceMeters = + device.locationPrivacyMinDistanceMeters ?? + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS + const minDistanceMeters = + configuredMinDistanceMeters < maxDistanceMeters + ? configuredMinDistanceMeters + : Math.max(0, maxDistanceMeters / 5) + const method = LOCATION_PRIVACY_METHOD + const seedLatitude = device.latitude.toFixed(6) + const seedLongitude = device.longitude.toFixed(6) + // Include rounded exact coordinates in the HMAC seed so moving a device also + // rotates the donut offset. This prevents observers from comparing old and new + // public points to recover the exact movement vector. + const [distanceUnit, bearingUnit] = randomUnitValues( + `${method}:${device.id}:${seedLatitude}:${seedLongitude}:${minDistanceMeters}:${maxDistanceMeters}`, + ) + // Pick a distance uniformly by ring area, not by radius. Without the square + // root, points would be overrepresented near the inner edge of the donut. + const distanceMeters = Math.sqrt( + minDistanceMeters ** 2 + + distanceUnit * (maxDistanceMeters ** 2 - minDistanceMeters ** 2), + ) + const masked = destinationPoint( + device.latitude, + device.longitude, + distanceMeters, + 2 * Math.PI * bearingUnit, + ) + + return { + ...masked, + disclosure: { + mode: 'masked', + accuracyMeters: maxDistanceMeters, + minDistanceMeters, + maxDistanceMeters, + method, + }, + } +} diff --git a/app/lib/image-sanitizer.server.ts b/app/lib/image-sanitizer.server.ts new file mode 100644 index 00000000..3ebecae2 --- /dev/null +++ b/app/lib/image-sanitizer.server.ts @@ -0,0 +1,52 @@ +import sharp, { type Sharp } from 'sharp' +import { + isSanitizableImageType, + type SanitizableImageType, +} from '~/lib/image-types' + +export type SanitizedImage = { + buffer: Buffer + contentType: SanitizableImageType + extension: string +} + +const IMAGE_OUTPUTS: Record< + SanitizableImageType, + { + extension: string + encode: (image: Sharp) => Sharp + } +> = { + 'image/jpeg': { + extension: 'jpg', + encode: (image) => image.jpeg(), + }, + 'image/png': { + extension: 'png', + encode: (image) => image.png(), + }, + 'image/webp': { + extension: 'webp', + encode: (image) => image.webp(), + }, + 'image/gif': { + extension: 'gif', + encode: (image) => image.gif(), + }, +} + +export async function sanitizeImageFile(file: File): Promise { + if (!isSanitizableImageType(file.type)) { + throw new Error(`Unsupported image type: ${file.type}`) + } + + const input = Buffer.from(await file.arrayBuffer()) + const output = IMAGE_OUTPUTS[file.type] + const image = sharp(input, { animated: file.type === 'image/gif' }).rotate() + + return { + buffer: await output.encode(image).toBuffer(), + contentType: file.type, + extension: output.extension, + } +} diff --git a/app/lib/image-types.ts b/app/lib/image-types.ts new file mode 100644 index 00000000..4bd1e23e --- /dev/null +++ b/app/lib/image-types.ts @@ -0,0 +1,14 @@ +export const SANITIZABLE_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +] as const + +export type SanitizableImageType = (typeof SANITIZABLE_IMAGE_TYPES)[number] + +export function isSanitizableImageType( + contentType: string, +): contentType is SanitizableImageType { + return SANITIZABLE_IMAGE_TYPES.includes(contentType as SanitizableImageType) +} diff --git a/app/lib/location.ts b/app/lib/location.ts index 7ab18e67..b75e5a68 100644 --- a/app/lib/location.ts +++ b/app/lib/location.ts @@ -11,6 +11,24 @@ export const LOCATION_LIMITS = { }, } as const +export const LOCATION_PRIVACY_VALUES = ['exact', 'masked'] as const +export const DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS = 20 +export const DEFAULT_LOCATION_PRIVACY_RADIUS_METERS = 50 +export const LOCATION_PRIVACY_RADIUS_VALUES = [50, 100, 250, 500] as const +export const LOCATION_PRIVACY_MIN_DISTANCE_VALUES = [ + 20, 50, 100, 250, +] as const +export const LOCATION_PRIVACY_DISTANCE_PRESETS = [ + { + min: DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + max: DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, + }, + { min: 50, max: 250 }, + { min: 100, max: 500 }, + { min: 250, max: 500 }, +] as const +export const LOCATION_PRIVACY_METHOD = 'stable-donut-displacement-v1' as const + export const MAP_ZOOM_LIMITS = { min: 1.5, max: 20, @@ -75,7 +93,46 @@ export const locationSchema = z.object({ ), }) +export const locationPrivacySchema = z + .object({ + locationPrivacy: z.enum(LOCATION_PRIVACY_VALUES).default('masked'), + locationPrivacyMinDistanceMeters: z.coerce + .number() + .refine( + ( + value, + ): value is (typeof LOCATION_PRIVACY_MIN_DISTANCE_VALUES)[number] => + LOCATION_PRIVACY_MIN_DISTANCE_VALUES.includes( + value as (typeof LOCATION_PRIVACY_MIN_DISTANCE_VALUES)[number], + ), + 'Location privacy minimum distance is invalid', + ) + .default(DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS), + locationPrivacyRadiusMeters: z.coerce + .number() + .refine( + (value): value is (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number] => + LOCATION_PRIVACY_RADIUS_VALUES.includes( + value as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ), + 'Location privacy radius is invalid', + ) + .default(DEFAULT_LOCATION_PRIVACY_RADIUS_METERS), + }) + .refine( + (value) => + value.locationPrivacy === 'exact' || + value.locationPrivacyMinDistanceMeters < + value.locationPrivacyRadiusMeters, + { + message: + 'Location privacy minimum distance must be smaller than the maximum radius', + path: ['locationPrivacyMinDistanceMeters'], + }, + ) + export type LocationData = z.infer +export type LocationPrivacyData = z.infer export function validLngLat(lng: number, lat: number): boolean { return locationSchema.safeParse({ @@ -157,6 +214,9 @@ export function getLocationFieldErrors(error: z.ZodError) { export type LocationFieldErrors = { latitude?: string longitude?: string + locationPrivacy?: string + locationPrivacyMinDistanceMeters?: string + locationPrivacyRadiusMeters?: string } export function validateLocationFieldErrors( @@ -171,6 +231,44 @@ export function validateLocationFieldErrors( return getLocationFieldErrors(parsed.error) } +export function parseLocationPrivacyFormData(formData: FormData): + | { + success: true + data: LocationPrivacyData + } + | { + success: false + errors: LocationFieldErrors + } { + const parsed = locationPrivacySchema.safeParse({ + locationPrivacy: formData.get('locationPrivacy'), + locationPrivacyMinDistanceMeters: formData.get( + 'locationPrivacyMinDistanceMeters', + ), + locationPrivacyRadiusMeters: formData.get('locationPrivacyRadiusMeters'), + }) + + if (parsed.success) { + return { + success: true, + data: parsed.data, + } + } + + const flattened = z.flattenError(parsed.error) + + return { + success: false, + errors: { + locationPrivacy: flattened.fieldErrors.locationPrivacy?.[0], + locationPrivacyMinDistanceMeters: + flattened.fieldErrors.locationPrivacyMinDistanceMeters?.[0], + locationPrivacyRadiusMeters: + flattened.fieldErrors.locationPrivacyRadiusMeters?.[0], + }, + } +} + export type OptionalMapViewportInput = { latitude: string longitude: string diff --git a/app/lib/openapi/schemas/device.ts b/app/lib/openapi/schemas/device.ts index 8f0c6529..3c238b1d 100644 --- a/app/lib/openapi/schemas/device.ts +++ b/app/lib/openapi/schemas/device.ts @@ -51,18 +51,27 @@ export const DeviceSensorUpdateSchema = z 'Existing sensor id. `_id` is used by the legacy API and is preferred for backwards compatibility.', example: '60a13611a877b3001b8ffd59', }), - new: z.union([z.literal(true), z.literal('true')]).optional().meta({ - description: 'Whether this sensor should be created as new.', - example: true, - }), - edited: z.union([z.literal(true), z.literal('true')]).optional().meta({ - description: 'Whether this sensor should be created or updated.', - example: true, - }), - deleted: z.union([z.literal(true), z.literal('true')]).optional().meta({ - description: 'Whether this sensor should be deleted.', - example: true, - }), + new: z + .union([z.literal(true), z.literal('true')]) + .optional() + .meta({ + description: 'Whether this sensor should be created as new.', + example: true, + }), + edited: z + .union([z.literal(true), z.literal('true')]) + .optional() + .meta({ + description: 'Whether this sensor should be created or updated.', + example: true, + }), + deleted: z + .union([z.literal(true), z.literal('true')]) + .optional() + .meta({ + description: 'Whether this sensor should be deleted.', + example: true, + }), title: z.string().optional().meta({ example: 'PM10', }), @@ -79,7 +88,7 @@ export const DeviceSensorUpdateSchema = z }) .transform(({ id, ...sensor }) => ({ ...sensor, - ...(sensor._id ?? id ? { _id: sensor._id ?? id } : {}), + ...((sensor._id ?? id) ? { _id: sensor._id ?? id } : {}), })) .meta({ id: 'DeviceSensorUpdate', @@ -150,6 +159,35 @@ export const ApiDeviceSchema = z description: 'Device longitude', example: 13.404954, }), + locationPrivacy: z.string().optional().meta({ + description: 'Stored public location privacy preference.', + example: 'masked', + }), + locationPrivacyRadiusMeters: z.number().optional().meta({ + description: 'Configured maximum masking radius in meters.', + example: 50, + }), + locationPrivacyMinDistanceMeters: z.number().optional().meta({ + description: 'Configured minimum donut masking distance in meters.', + example: 20, + }), + locationPrivacyMethod: z.string().optional().meta({ + description: 'Configured location masking method version.', + example: 'stable-donut-displacement-v1', + }), + locationDisclosure: z + .object({ + mode: z.enum(['exact', 'masked']), + accuracyMeters: z.number(), + minDistanceMeters: z.number(), + maxDistanceMeters: z.number(), + method: z.string().nullable(), + }) + .optional() + .meta({ + description: + 'Describes whether returned coordinates are exact or geomasked.', + }), useAuth: z.boolean().optional().meta({ description: 'Whether the device requires authentication', example: true, diff --git a/app/lib/s3.server.ts b/app/lib/s3.server.ts index fbf0836f..4db79860 100644 --- a/app/lib/s3.server.ts +++ b/app/lib/s3.server.ts @@ -7,6 +7,7 @@ import { CreateBucketCommand, } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { sanitizeImageFile } from '~/lib/image-sanitizer.server' const S3_ENDPOINT = ( process.env.S3_ENDPOINT || 'http://localhost:9000' @@ -56,17 +57,15 @@ export async function uploadDeviceImage( ): Promise { await ensureBucketExists() - const fileExtension = file.name.split('.').pop() - const key = `devices/${deviceId}.${fileExtension}` - - const buffer = Buffer.from(await file.arrayBuffer()) + const image = await sanitizeImageFile(file) + const key = `devices/${deviceId}.${image.extension}` await s3Client.send( new PutObjectCommand({ Bucket: BUCKET_NAME, Key: key, - Body: buffer, - ContentType: file.type, + Body: image.buffer, + ContentType: image.contentType, }), ) diff --git a/app/routes/api.boxes.$deviceId.ts b/app/routes/api.boxes.$deviceId.ts index bf4f2fa4..f7f7b6b2 100644 --- a/app/routes/api.boxes.$deviceId.ts +++ b/app/routes/api.boxes.$deviceId.ts @@ -38,6 +38,11 @@ import { } from '~/middleware/content-type-header.server' import { parseJsonBody } from '~/lib/request-parsing' import { LocationObjectSchema } from '~/lib/openapi/schemas/location' +import { + LOCATION_PRIVACY_MIN_DISTANCE_VALUES, + LOCATION_PRIVACY_RADIUS_VALUES, + LOCATION_PRIVACY_VALUES, +} from '~/lib/location' const messages = { conflictingSensorsAndAddons: @@ -86,6 +91,46 @@ const UpdateDeviceRequestSchema = z location: LocationObjectSchema.optional(), + locationPrivacy: z.enum(LOCATION_PRIVACY_VALUES).optional().meta({ + description: + 'Whether public responses expose the exact or masked location.', + example: 'masked', + }), + + locationPrivacyMinDistanceMeters: z + .number() + .refine( + ( + value, + ): value is (typeof LOCATION_PRIVACY_MIN_DISTANCE_VALUES)[number] => + LOCATION_PRIVACY_MIN_DISTANCE_VALUES.includes( + value as (typeof LOCATION_PRIVACY_MIN_DISTANCE_VALUES)[number], + ), + 'Location privacy minimum distance is invalid', + ) + .optional() + .meta({ + description: + 'Minimum distance in meters used by donut geomasking when `locationPrivacy` is `masked`.', + example: 20, + }), + + locationPrivacyRadiusMeters: z + .number() + .refine( + (value): value is (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number] => + LOCATION_PRIVACY_RADIUS_VALUES.includes( + value as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ), + 'Location privacy radius is invalid', + ) + .optional() + .meta({ + description: + 'Maximum radius in meters used by donut geomasking when `locationPrivacy` is `masked`.', + example: 50, + }), + grouptag: z .union([z.string(), z.array(z.string())]) .transform((value) => (Array.isArray(value) ? value : [value])) @@ -102,6 +147,19 @@ const UpdateDeviceRequestSchema = z addons: DeviceAddonsUpdateSchema.optional(), }) + .refine( + (value) => + value.locationPrivacy !== 'masked' || + value.locationPrivacyMinDistanceMeters === undefined || + value.locationPrivacyRadiusMeters === undefined || + value.locationPrivacyMinDistanceMeters < + value.locationPrivacyRadiusMeters, + { + message: + 'Location privacy minimum distance must be smaller than the maximum radius', + path: ['locationPrivacyMinDistanceMeters'], + }, + ) .superRefine((body, ctx) => { if (body.sensors && body.addons?.add) { ctx.addIssue({ @@ -452,6 +510,9 @@ async function put(request: Request, user: User, deviceId: string) { useAuth: body.useAuth, link: body.weblink, location: locationData, + locationPrivacy: body.locationPrivacy, + locationPrivacyMinDistanceMeters: body.locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters: body.locationPrivacyRadiusMeters, grouptag: body.grouptag, sensors: body.sensors, } @@ -473,7 +534,9 @@ async function put(request: Request, user: User, deviceId: string) { return StandardResponse.internalServerError() } - const apiResponse = transformDeviceToApiFormat(deviceWithSensors) + const apiResponse = transformDeviceToApiFormat(deviceWithSensors, { + includeExactLocation: true, + }) const responseParsed = await ApiDeviceSchema.safeParseAsync(apiResponse) diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 8d6753cb..b376e854 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -4,8 +4,9 @@ import { findDevices, type FindDevicesOptions, } from '~/db/models/device.server' -import { type Device, type User } from '~/db/schema' +import { type User } from '~/db/schema' import { transformDeviceToApiFormat } from '~/lib/device-transform' +import { getPublicLocation } from '~/lib/geomasking.server' import { StandardResponse } from '~/lib/responses' import { type ZodOpenApiPathItemObject } from 'zod-openapi' @@ -165,9 +166,21 @@ export async function loader({ request }: Route.LoaderArgs) { const devices = await findDevices(params) if (params.format === 'geojson') { + const transformedDevices = params.minimal + ? devices.map((device) => { + const publicLocation = getPublicLocation(device as any) + + return { + ...device, + latitude: publicLocation.latitude, + longitude: publicLocation.longitude, + locationDisclosure: publicLocation.disclosure, + } + }) + : devices.map((device) => transformDeviceToApiFormat(device)) const geojson = { type: 'FeatureCollection', - features: devices.map((device: Device) => ({ + features: transformedDevices.map((device) => ({ type: 'Feature', geometry: { type: 'Point', @@ -185,11 +198,33 @@ export async function loader({ request }: Route.LoaderArgs) { }, }) } - return Response.json(devices, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + if (params.minimal) { + return Response.json( + devices.map((device) => { + const publicLocation = getPublicLocation(device as any) + + return { + ...device, + latitude: publicLocation.latitude, + longitude: publicLocation.longitude, + locationDisclosure: publicLocation.disclosure, + } + }), + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }, + ) + } + return Response.json( + devices.map((device) => transformDeviceToApiFormat(device)), + { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, }, - }) + ) } export const action = async ({ request }: Route.ActionArgs) => { @@ -235,6 +270,10 @@ async function post(request: Request, user: User) { model: sensorsProvided ? undefined : validatedData.model, latitude: latitude, longitude: longitude, + locationPrivacy: validatedData.locationPrivacy, + locationPrivacyMinDistanceMeters: + validatedData.locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters: validatedData.locationPrivacyRadiusMeters, tags: validatedData.grouptag, sensors: sensorsProvided ? validatedData.sensors.map((s) => ({ @@ -248,7 +287,9 @@ async function post(request: Request, user: User) { ) // Build response object using helper function - const responseData = transformDeviceToApiFormat(newDevice) + const responseData = transformDeviceToApiFormat(newDevice, { + includeExactLocation: true, + }) return StandardResponse.created(responseData) } catch { diff --git a/app/routes/api.users.me.boxes.$deviceId.ts b/app/routes/api.users.me.boxes.$deviceId.ts index 077faada..9f5f5fb4 100644 --- a/app/routes/api.users.me.boxes.$deviceId.ts +++ b/app/routes/api.users.me.boxes.$deviceId.ts @@ -107,7 +107,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => { await GetCurrentUserDeviceResponseSchema.safeParseAsync({ code: 'Ok', data: { - device: transformDeviceToApiFormat(device), + device: transformDeviceToApiFormat(device, { + includeExactLocation: true, + }), }, }) diff --git a/app/routes/api.users.me.boxes.ts b/app/routes/api.users.me.boxes.ts index 578acba9..84e002d1 100644 --- a/app/routes/api.users.me.boxes.ts +++ b/app/routes/api.users.me.boxes.ts @@ -86,7 +86,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => { const userBoxes = await getUserDevices(user.id) const transformedBoxes = userBoxes.map((box) => - transformDeviceToApiFormat(box), + transformDeviceToApiFormat(box, { includeExactLocation: true }), ) const boxesWithIntegrations = diff --git a/app/routes/device.$deviceId.edit.location.tsx b/app/routes/device.$deviceId.edit.location.tsx index 7b657520..d82a75d3 100644 --- a/app/routes/device.$deviceId.edit.location.tsx +++ b/app/routes/device.$deviceId.edit.location.tsx @@ -16,12 +16,18 @@ import { import { getUserId } from '~/services/session-service.server' import { BaseMap } from '~/components/base-map' import { + DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, LOCATION_LIMITS, + LOCATION_PRIVACY_DISTANCE_PRESETS, + LOCATION_PRIVACY_RADIUS_VALUES, isValidLocation, parseLocationFormData, + parseLocationPrivacyFormData, validateLocationFieldErrors, type LocationData, type LocationFieldErrors, + type LocationPrivacyData, } from '~/lib/location' import { useTranslation } from 'react-i18next' import { @@ -46,7 +52,7 @@ function normalizeCoordinate(value: number | null) { return Number(value.toFixed(6)) } -function normalizeLocationValues(values: LocationAutosaveValues) { +function normalizeLocationValues(values: MarkerValue) { return { latitude: normalizeCoordinate(values.latitude), longitude: normalizeCoordinate(values.longitude), @@ -62,6 +68,7 @@ export type LocationActionData = | { ok: true location: LocationData + locationPrivacy: LocationPrivacyData errors: null savedAt: string } @@ -73,6 +80,9 @@ export type LocationActionData = type LocationAutosaveValues = { latitude: number | null longitude: number | null + locationPrivacy: LocationPrivacyData['locationPrivacy'] + locationPrivacyMinDistanceMeters: LocationPrivacyData['locationPrivacyMinDistanceMeters'] + locationPrivacyRadiusMeters: LocationPrivacyData['locationPrivacyRadiusMeters'] } //***************************************************** @@ -117,12 +127,16 @@ export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData() const parsed = parseLocationFormData(formData) + const privacyParsed = parseLocationPrivacyFormData(formData) - if (!parsed.success) { + if (!parsed.success || !privacyParsed.success) { return data( { ok: false as const, - errors: parsed.errors, + errors: { + ...(parsed.success ? {} : parsed.errors), + ...(privacyParsed.success ? {} : privacyParsed.errors), + }, }, { status: 400 }, ) @@ -132,11 +146,16 @@ export async function action({ request, params }: Route.ActionArgs) { id, latitude: parsed.data.latitude, longitude: parsed.data.longitude, + locationPrivacy: privacyParsed.data.locationPrivacy, + locationPrivacyMinDistanceMeters: + privacyParsed.data.locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters: privacyParsed.data.locationPrivacyRadiusMeters, }) return data({ ok: true as const, location: parsed.data, + locationPrivacy: privacyParsed.data, errors: null, savedAt: new Date().toISOString(), }) @@ -154,8 +173,47 @@ export default function EditLocation() { }), [device.latitude, device.longitude], ) + const initialLocationPrivacy = useMemo( + () => ({ + locationPrivacy: device.locationPrivacy === 'masked' ? 'masked' : 'exact', + locationPrivacyMinDistanceMeters: + device.locationPrivacyMinDistanceMeters && + LOCATION_PRIVACY_DISTANCE_PRESETS.some( + (preset) => + preset.min === device.locationPrivacyMinDistanceMeters && + preset.max === device.locationPrivacyRadiusMeters, + ) + ? (device.locationPrivacyMinDistanceMeters as LocationPrivacyData['locationPrivacyMinDistanceMeters']) + : DEFAULT_LOCATION_PRIVACY_MIN_DISTANCE_METERS, + locationPrivacyRadiusMeters: + device.locationPrivacyRadiusMeters && + LOCATION_PRIVACY_RADIUS_VALUES.includes( + device.locationPrivacyRadiusMeters as (typeof LOCATION_PRIVACY_RADIUS_VALUES)[number], + ) + ? (device.locationPrivacyRadiusMeters as LocationPrivacyData['locationPrivacyRadiusMeters']) + : DEFAULT_LOCATION_PRIVACY_RADIUS_METERS, + }), + [ + device.locationPrivacy, + device.locationPrivacyMinDistanceMeters, + device.locationPrivacyRadiusMeters, + ], + ) const [marker, setMarker] = useState(initialLocation) + const [locationPrivacy, setLocationPrivacy] = useState< + LocationPrivacyData['locationPrivacy'] + >(initialLocationPrivacy.locationPrivacy) + const [ + locationPrivacyMinDistanceMeters, + setLocationPrivacyMinDistanceMeters, + ] = useState( + initialLocationPrivacy.locationPrivacyMinDistanceMeters, + ) + const [locationPrivacyRadiusMeters, setLocationPrivacyRadiusMeters] = + useState( + initialLocationPrivacy.locationPrivacyRadiusMeters, + ) const currentLocation = useMemo(() => { const candidate = { @@ -181,6 +239,11 @@ export default function EditLocation() { return { latitude: String(values.latitude), longitude: String(values.longitude), + locationPrivacy: values.locationPrivacy, + locationPrivacyMinDistanceMeters: String( + values.locationPrivacyMinDistanceMeters, + ), + locationPrivacyRadiusMeters: String(values.locationPrivacyRadiusMeters), } }, []) @@ -195,23 +258,45 @@ export default function EditLocation() { ): LocationAutosaveValues => { if (!actionData.ok) return submittedValues - return normalizeLocationValues(submittedValues) + return { + ...normalizeLocationValues(submittedValues), + locationPrivacy: actionData.locationPrivacy.locationPrivacy, + locationPrivacyMinDistanceMeters: + actionData.locationPrivacy.locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters: + actionData.locationPrivacy.locationPrivacyRadiusMeters, + } }, [], ) const autosaveValues = useMemo( () => - normalizeLocationValues({ - latitude: marker.latitude, - longitude: marker.longitude, - }), - [marker.latitude, marker.longitude], + ({ + ...normalizeLocationValues({ + latitude: marker.latitude, + longitude: marker.longitude, + }), + locationPrivacy, + locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters, + }) as LocationAutosaveValues, + [ + marker.latitude, + marker.longitude, + locationPrivacy, + locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters, + ], ) const initialAutosaveValues = useMemo( - () => normalizeLocationValues(initialLocation), - [initialLocation], + () => + ({ + ...normalizeLocationValues(initialLocation), + ...initialLocationPrivacy, + }) as LocationAutosaveValues, + [initialLocation, initialLocationPrivacy], ) const autosave = useAutosaveFetcher< @@ -237,6 +322,10 @@ export default function EditLocation() { const locationErrors = { latitude: clientErrors.latitude ?? serverErrors.latitude, longitude: clientErrors.longitude ?? serverErrors.longitude, + locationPrivacy: serverErrors.locationPrivacy, + locationPrivacyMinDistanceMeters: + serverErrors.locationPrivacyMinDistanceMeters, + locationPrivacyRadiusMeters: serverErrors.locationPrivacyRadiusMeters, } const hasClientErrors = Boolean( @@ -275,6 +364,35 @@ export default function EditLocation() { })) } + const onLocationPrivacyChange = ( + event: React.ChangeEvent, + ) => { + setLocationPrivacy(event.target.value === 'masked' ? 'masked' : 'exact') + } + + const onLocationPrivacyPresetChange = ( + event: React.ChangeEvent, + ) => { + const [minDistance, maxDistance] = event.target.value.split(':').map(Number) + + const preset = LOCATION_PRIVACY_DISTANCE_PRESETS.find( + (candidate) => + candidate.min === minDistance && candidate.max === maxDistance, + ) + + if (!preset) return + + setLocationPrivacyMinDistanceMeters( + preset.min as LocationPrivacyData['locationPrivacyMinDistanceMeters'], + ) + setLocationPrivacyRadiusMeters( + preset.max as LocationPrivacyData['locationPrivacyRadiusMeters'], + ) + } + + const formatDistance = (meters: number) => + meters >= 1000 ? `${meters / 1000} km` : `${meters} m` + const resetToOriginalLocation = () => { setMarker({ ...originalLocation }) } @@ -410,6 +528,107 @@ export default function EditLocation() { +
+
+ + {t('public_location')} + + +
+ + + +
+
+ +
+ + + + + + + {locationErrors.locationPrivacy || + locationErrors.locationPrivacyMinDistanceMeters || + locationErrors.locationPrivacyRadiusMeters ? ( +

+ {locationErrors.locationPrivacy ?? + locationErrors.locationPrivacyMinDistanceMeters ?? + locationErrors.locationPrivacyRadiusMeters} +

+ ) : null} +
+
+