diff --git a/api/README.md b/api/README.md index bde4e858..b0551e48 100644 --- a/api/README.md +++ b/api/README.md @@ -167,5 +167,12 @@ firebase deploy --only functions:createStripeCustomer,functions:createOrRetrieve See [the docs](https://firebase.google.com/docs/rules/manage-deploy#deploy_your_updates). ``` +# For Firestore firebase deploy --only firestore:rules + +# For Storage +firebase deploy --only storage + +# Both +firebase deploy --only firestore:rules,storage ``` diff --git a/firestore.rules b/firestore.rules index 71375b63..25c92e23 100644 --- a/firestore.rules +++ b/firestore.rules @@ -32,6 +32,10 @@ service cloud.firestore { return request.auth.token.admin; } + function isMember() { + return get(/databases/$(database)/documents/users/$(requesterId())).data.superfan == true; + } + function requesterId() { return request.auth.uid; } @@ -90,6 +94,17 @@ service cloud.firestore { && !request.resource.data.diff(resource.data).affectedKeys().hasAny(['stripeCustomerId', 'stripeSubscription', 'sendgridId', 'oldEmail', 'newEmail']) } + function validateTrailState(trail) { + return isNonEmptyString(trail.originalFileName) + && isNonEmptyString(trail.md5Hash) + && trail.visible is bool + } + + function isValidTrailAccess(userId) { + // Only superfans (members) can access trails + return isSignedIn() && isOwner(userId) && isMember() + } + match /users-private/{userId} { // users-private documents are always created by firebase-admin. allow read: if isOwner(userId); @@ -97,6 +112,16 @@ service cloud.firestore { if isSignedIn() && isOwner(userId) && validateUserPrivateState(request.resource.data) + match /trails/{trailId} { + allow read: if isValidTrailAccess(userId); + allow create: if isValidTrailAccess(userId) + && validateTrailState(incomingData()) + allow update: if isValidTrailAccess(userId) + // Only the visibility should be updateable by the user, once the trail is created. + && incomingData().diff(existingData()).affectedKeys().hasOnly(['visible']) + && incomingData().visible is bool; + allow delete: if isValidTrailAccess(userId); + } } // Garden functions @@ -251,7 +276,7 @@ service cloud.firestore { // The first user in the participating `users` array must be the chat creator (first message sender) && incomingData().users[0] == requesterId() // Only superfans (members) can create chats - && get(/databases/$(database)/documents/users/$(requesterId())).data.superfan == true + && isMember() // Both participants exist && userExists(incomingData().users[0]) && userExists(incomingData().users[1]) @@ -299,9 +324,7 @@ service cloud.firestore { } } - match /tmp-users/{userId} { - allow read, write: if false - } + // Stats match /stats/{type} { allow read: if isSignedIn() && isAdmin(); @@ -315,5 +338,6 @@ service cloud.firestore { match /stats/campsites { allow read: if true; } + } } diff --git a/package.json b/package.json index 07f5161f..178e9d62 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@tmcw/togeojson": "^5.5.0", "@turf/turf": "^6.5.0", "@types/lodash-es": "^4.17.7", + "@types/md5": "^2.3.2", "@types/nprogress": "^0.2.0", "@types/smoothscroll-polyfill": "^0.3.1", "@typescript-eslint/eslint-plugin": "^5.27.0", @@ -51,6 +52,7 @@ "iso-639-1": "^2.1.9", "lodash-es": "^4.17.21", "maplibre-gl": "^1", + "md5": "^2.3.0", "nprogress": "^0.2.0", "prettier": "^2.6.2", "prettier-plugin-svelte": "^2.10.1", diff --git a/src/lib/api/collections.ts b/src/lib/api/collections.ts index b40996aa..77458e6b 100644 --- a/src/lib/api/collections.ts +++ b/src/lib/api/collections.ts @@ -4,3 +4,4 @@ export const STATS = 'stats'; export const USERS = 'users'; export const USERS_PRIVATE = 'users-private'; export const MESSAGES = 'messages'; +export const TRAILS = 'trails'; diff --git a/src/lib/api/trail.ts b/src/lib/api/trail.ts new file mode 100644 index 00000000..07a49437 --- /dev/null +++ b/src/lib/api/trail.ts @@ -0,0 +1,151 @@ +import { deleteObject, getDownloadURL, ref, uploadString } from 'firebase/storage'; +import { db, storage } from './firebase'; +import { getUser } from '$lib/stores/auth'; +import type GeoJSON from 'geojson'; +import { + CollectionReference, + DocumentReference, + collection, + deleteDoc, + doc, + onSnapshot, + query, + setDoc, + updateDoc +} from 'firebase/firestore'; +import { TRAILS, USERS_PRIVATE } from './collections'; +import type { FirebaseTrail, LocalTrail } from '$lib/types/Trail'; +import md5 from 'md5'; +import { + addFileDataLayers, + findFileDataLayer, + findFileDataLayerByMD5Hash, + removeFileDataLayers, + updateFileDataLayers +} from '$lib/stores/file'; + +const getFileRef = (fileId: string) => ref(storage(), `trails/${getUser().uid}/${fileId}`); + +export const createTrailObserver = () => { + const q = query( + collection(db(), USERS_PRIVATE, getUser().id, TRAILS) as CollectionReference + ); + + return onSnapshot(q, async (querySnapshot) => { + const changes = querySnapshot.docChanges(); + changes.map(async (change) => { + const trail: LocalTrail = { + id: change.doc.id, + animate: false, + ...change.doc.data() + }; + if (change.type === 'added') { + const existingLocalLayer = findFileDataLayer(trail.id); + if (!existingLocalLayer) { + // Sync a *remote* addition to the local cache + // Locally added files are added before they are uploaded (see `createTrail()`). + // + // Download the trail file + const ref = getFileRef(trail.id); + const geoJson = await getDownloadURL(ref) + .then((url) => fetch(url)) + .then((r) => r.json()); + addFileDataLayers({ + ...trail, + geoJson + }); + } + } else if (change.type === 'removed') { + // Sync the deletion to the local cache + const existingLocalLayer = findFileDataLayer(trail.id); + if (existingLocalLayer) { + removeFileDataLayers(trail.id); + } + } else if (change.type === 'modified') { + // For now, only the visiblity can be modified + updateFileDataLayers(trail.id, trail); + } + }); + }); +}; + +export const createTrail = async ({ + /** + * Original file name + */ + name, + geoJson +}: { + name: string; + geoJson: GeoJSON.FeatureCollection | GeoJSON.Feature; +}) => { + const uid = getUser().id; + + // Calculate the file's MD5 checksum + const jsonString = JSON.stringify(geoJson); + const md5Hash = md5(jsonString); + + // Check if the file already exists + const existingFile = findFileDataLayerByMD5Hash(md5Hash); + if (existingFile) { + console.error('This file already exists'); + return; + } + + // First, create a local Firestore doc reference explicitly, so it's ID can be used + const docRef = doc( + collection(db(), USERS_PRIVATE, uid, TRAILS) + ) as DocumentReference; + + // Immediately show the file locally + addFileDataLayers({ + id: docRef.id, + originalFileName: name, + geoJson, + md5Hash, + visible: true, + animate: true + }); + + // Next, upload the file for persistence + const fileRef = getFileRef(docRef.id); + await uploadString(fileRef, jsonString, undefined, { + // TODO: should we rename extensions to .geojson? + contentType: 'application/geo+json', + customMetadata: { + originalFileName: name + } + }); + + // Set the remote document content + await setDoc(docRef, { + originalFileName: name, + // Default + md5Hash, + visible: true + }); +}; + +export const toggleTrailVisibility = async (id: string) => { + const existingFile = findFileDataLayer(id); + if (!existingFile) { + console.error('The visibility of this trail can not be changed'); + return; + } + const ref = doc(db(), USERS_PRIVATE, getUser().id, TRAILS, id); + await updateDoc(ref, { visible: !existingFile.visible }); +}; + +export const deleteTrail = async (id: string) => { + const existingFile = findFileDataLayer(id); + if (!existingFile) { + console.error('This trail does not exist'); + return; + } + + const ref = getFileRef(id); + // Delete the storage object + await deleteObject(ref); + // Delete the metadata + await deleteDoc(doc(db(), USERS_PRIVATE, getUser().id, TRAILS, id)); +}; diff --git a/src/lib/components/LayersAndTools/TrailsTool.svelte b/src/lib/components/LayersAndTools/TrailsTool.svelte index 5183ea62..c1a0083c 100644 --- a/src/lib/components/LayersAndTools/TrailsTool.svelte +++ b/src/lib/components/LayersAndTools/TrailsTool.svelte @@ -3,13 +3,10 @@ import { LabeledCheckbox, MultiActionLabel, Button, Icon } from '$lib/components/UI'; import { cyclistIcon, hikerIcon, routesIcon } from '$lib/images/icons'; - import { - fileDataLayers, - removeFileDataLayers, - toggleVisibilityFileDataLayers - } from '$lib/stores/file'; + import { fileDataLayers } from '$lib/stores/file'; import { cleanName } from '$lib/util/slugify'; import { onDestroy } from 'svelte'; + import { deleteTrail, toggleTrailVisibility } from '$lib/api/trail'; export let showHiking: boolean; export let showCycling: boolean; @@ -41,14 +38,10 @@ { - toggleVisibilityFileDataLayers(layer.id); - }} - on:secondary={() => { - removeFileDataLayers(layer.id); - }} + on:change={() => toggleTrailVisibility(layer.id)} + on:secondary={() => deleteTrail(layer.id)} /> {/each} diff --git a/src/lib/components/Map/FileTrailModal.svelte b/src/lib/components/Map/FileTrailModal.svelte index 71aa4078..fbeab433 100644 --- a/src/lib/components/Map/FileTrailModal.svelte +++ b/src/lib/components/Map/FileTrailModal.svelte @@ -5,7 +5,6 @@ import { keyboardEvent } from '$lib/stores/keyboardEvent'; import { VALID_FILETYPE_EXTENSIONS } from '$lib/constants'; import { getFileExtension } from '$lib/util'; - import { addFileDataLayers } from '$lib/stores/file'; import Icon from '$lib/components/UI/Icon.svelte'; import { crossIcon, uploadCloudIcon } from '$lib/images/icons'; import Text from '$lib/components/UI/Text.svelte'; @@ -14,6 +13,7 @@ import notification from '$lib/stores/notification'; import trackEvent from '$lib/util/track-plausible'; import { PlausibleEvent } from '$lib/types/Plausible'; + import { createTrail } from '$lib/api/trail'; export let show = false; let files: File[] = []; @@ -43,10 +43,7 @@ try { const geoJson = await fileToGeoJson(file); - addFileDataLayers({ - name: file.name, - geoJson: geoJson - }); + createTrail({ name: file.name, geoJson }); return true; } catch (error) { notification.warning('Error while processing file', 5000); diff --git a/src/lib/components/Map/FileTrails.svelte b/src/lib/components/Map/FileTrails.svelte index ea2d607b..d5623115 100644 --- a/src/lib/components/Map/FileTrails.svelte +++ b/src/lib/components/Map/FileTrails.svelte @@ -6,6 +6,7 @@ import bbox from '@turf/bbox'; import key from './mapbox-context.js'; import { ZOOM_LEVELS } from '$lib/constants.js'; + import type { FileDataLayer } from '$lib/types/DataLayer'; type SourceData = | string @@ -24,23 +25,27 @@ } }; - const addTrail = (geoJson: SourceData, id: string) => { - try { - const bboxBounds = <[number, number, number, number]>bbox(geoJson).slice(0, 4); - map.fitBounds(bboxBounds, { - padding: { - top: 150, - bottom: 150, - left: 50, - right: 50 - }, - maxZoom: ZOOM_LEVELS.ROAD, - linear: true - }); - } catch (error) { - console.error(error); + const addTrail = ({ geoJson, id, animate }: FileDataLayer) => { + // Zoom & pan the map to show the trail, but only if we added the route locally. + if (animate) { + try { + const bboxBounds = <[number, number, number, number]>bbox(geoJson).slice(0, 4); + map.fitBounds(bboxBounds, { + padding: { + top: 150, + bottom: 150, + left: 50, + right: 50 + }, + maxZoom: ZOOM_LEVELS.ROAD, + linear: true + }); + } catch (error) { + console.error(error); + } } + // Add/update layer data if (map.getSource(id)) { map.getSource(id).setData(geoJson); } else { @@ -50,6 +55,7 @@ }); } + // Add a presentation if not existing yet if (!map.getLayer(id)) { map.addLayer({ id, @@ -142,7 +148,7 @@ // Add new layers idsToAdd.map((id) => { const fileDataLayer = fileDataLayers.find((fileDataLayer) => fileDataLayer.id === id); - if (fileDataLayer) addTrail(fileDataLayer.geoJson, id); + if (fileDataLayer) addTrail(fileDataLayer); }); // Remove old layers diff --git a/src/lib/components/UI/LabelWithIcon.svelte b/src/lib/components/UI/LabelWithIcon.svelte index 0342dd77..980ebdf4 100644 --- a/src/lib/components/UI/LabelWithIcon.svelte +++ b/src/lib/components/UI/LabelWithIcon.svelte @@ -4,6 +4,9 @@ export let labelFor: undefined | string = undefined; export let ellipsis = false; export let compact = false; + // Intended for hover states when ellipsis is enabled. + // TODO: This could be made more accessible. + export let title: undefined | string = undefined;